[
  {
    "path": ".dockerignore",
    "content": "# 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\n.mypy_cache/\n.pytest_cache/\n.ruff_cache/\n\n# Distribution / packaging\nbuild/\ndist/\n*.egg-info*\n\n# Virtual environment\n.env\n.venv/\nvenv/\n\n# IntelliJ IDEA\n.idea/\n\n# Visual Studio\n.vscode/\n\n# Test and development files\ntest-datastore/\ntests/\n*.md\n!README.md\n\n# Temporary and log files\n*.log\n*.tmp\ntmp/\ntemp/\n\n# Training data and large files\ntrain-data/\nworks-data/\n\n# Container files\nDockerfile*\ndocker-compose*.yml\n.dockerignore\n\n# Development certificates and keys\n*.pem\n*.key\n*.crt\nprofile_output.prof\n\n# Large binary files that shouldn't be in container\n*.pdf\nchrome.json"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: dgtlmoon\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a bug report, if you don't follow this template, your report will be DELETED\ntitle: ''\nlabels: 'triage'\nassignees: 'dgtlmoon'\n\n---\n\n**DO NOT USE THIS FORM TO REPORT THAT A PARTICULAR WEBSITE IS NOT SCRAPING/WATCHING AS EXPECTED**\n\nThis form is only for direct bugs and feature requests todo directly with the software.\n\nPlease 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\n\nCONSIDER 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\n\nTHANK YOU\n\n\n\n\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**Version**\n*Exact version* in the top right area: 0....\n\n**How did you install?**\n\nDocker, Pip, from source directly etc\n\n**To Reproduce**\n\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n! 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!\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS] \n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: '[feature]'\nlabels: 'enhancement'\nassignees: ''\n\n---\n**Version and OS**\nFor example, 0.123 on linux/docker\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe the use-case and give concrete real-world examples**\nAttach any HTML/JSON, give links to sites, screenshots etc, we are not mind readers\n\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/actions/extract-memory-report/action.yml",
    "content": "name: 'Extract Memory Test Report'\ndescription: 'Extracts and displays memory test report from a container'\ninputs:\n  container-name:\n    description: 'Name of the container to extract logs from'\n    required: true\n  python-version:\n    description: 'Python version for artifact naming'\n    required: true\n  output-dir:\n    description: 'Directory to store output logs'\n    required: false\n    default: 'output-logs'\n\nruns:\n  using: \"composite\"\n  steps:\n    - name: Create output directory\n      shell: bash\n      run: |\n        mkdir -p ${{ inputs.output-dir }}\n\n    - name: Dump container log\n      shell: bash\n      run: |\n        echo \"Disabled for now\"\n#        return\n#        docker logs ${{ inputs.container-name }} > ${{ inputs.output-dir }}/${{ inputs.container-name }}-stdout-${{ inputs.python-version }}.txt 2>&1 || echo \"Could not get stdout\"\n#        docker logs ${{ inputs.container-name }} 2> ${{ inputs.output-dir }}/${{ inputs.container-name }}-stderr-${{ inputs.python-version }}.txt || echo \"Could not get stderr\"\n\n    - name: Extract and display memory test report\n      shell: bash\n      run: |\n        echo \"Disabled for now\"\n#        echo \"Extracting test-memory.log from container...\"\n#        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\"\n#\n#        echo \"=== Top 10 Highest Peak Memory Tests ===\"\n#        if [ -f ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log ]; then\n#          grep \"Peak memory:\" ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log | \\\n#            sed 's/.*Peak memory: //' | \\\n#            paste -d'|' - <(grep \"Peak memory:\" ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log) | \\\n#            sort -t'|' -k1 -nr | \\\n#            cut -d'|' -f2 | \\\n#            head -10\n#          echo \"\"\n#          echo \"=== Full Memory Test Report ===\"\n#          cat ${{ inputs.output-dir }}/test-memory-${{ inputs.python-version }}.log\n#        else\n#          echo \"No memory log available\"\n#        fi\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: \"weekly\"\n    groups:\n      all:\n        patterns:\n        - \"*\"\n  - package-ecosystem: pip\n    directory: /\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/nginx-reverse-proxy-test.conf",
    "content": "server {\n    listen 80;\n    server_name localhost;\n\n    # Test basic reverse proxy to changedetection.io\n    location / {\n        proxy_pass http://changedet-app:5000;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        # WebSocket support\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n\n    # Test subpath deployment with X-Forwarded-Prefix\n    location /changedet-sub/ {\n        proxy_pass http://changedet-app:5000/;\n        proxy_set_header X-Forwarded-Prefix /changedet-sub;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        # WebSocket support\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n"
  },
  {
    "path": ".github/test/Dockerfile-alpine",
    "content": "# Taken from https://github.com/linuxserver/docker-changedetection.io/blob/main/Dockerfile\n# Test that we can still build on Alpine (musl modified libc https://musl.libc.org/)\n# Some packages wont install via pypi because they dont have a wheel available under this architecture.\n\nFROM ghcr.io/linuxserver/baseimage-alpine:3.22\nENV PYTHONUNBUFFERED=1\n\nCOPY requirements.txt /requirements.txt\n\nARG TARGETPLATFORM\n\nRUN \\\n apk add --update --no-cache --virtual=build-dependencies \\\n    build-base \\\n    cargo \\\n    git \\\n    jpeg-dev \\\n    libc-dev \\\n    libffi-dev \\\n    libxslt-dev \\\n    openssl-dev \\\n    python3-dev \\\n    file \\\n    zip \\\n    zlib-dev && \\\n  apk add --update --no-cache \\\n    libjpeg \\\n    libxslt \\\n    file \\\n    nodejs \\\n    poppler-utils \\\n    python3 \\\n    glib \\\n    libsm \\\n    libxext \\\n    libxrender && \\\n  case \"$TARGETPLATFORM\" in \\\n    linux/arm/v7|linux/arm/v8) \\\n      echo \"INFO: Skipping py3-opencv on $TARGETPLATFORM (using pixelmatch fallback)\" \\\n      ;; \\\n    *) \\\n      apk add --update --no-cache py3-opencv || echo \"WARN: py3-opencv install failed, using pixelmatch fallback\" \\\n      ;; \\\n  esac && \\\n  echo \"**** pip3 install test of changedetection.io ****\" && \\\n  python3 -m venv /lsiopy  && \\\n  pip install -U pip wheel setuptools && \\\n  pip install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.22/ -r /requirements.txt && \\\n  apk del --purge \\\n    build-dependencies\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  schedule:\n    - cron: '27 9 * * 4'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'javascript', 'python' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]\n        # Learn more:\n        # 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\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v6\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v4\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n        # queries: ./path/to/local/query, your-org/your-repo/queries@main\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v4\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 https://git.io/JvXDl\n\n    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines\n    #    and modify them (or add more) to build your code if your project\n    #    uses a compiled language\n\n    #- run: |\n    #   make bootstrap\n    #   make release\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v4\n"
  },
  {
    "path": ".github/workflows/containers.yml",
    "content": "name: Build and push containers\n\non:\n  # Automatically triggered by a testing workflow passing, but this is only checked when it lands in the `master`/default branch\n#  workflow_run:\n#    workflows: [\"ChangeDetection.io Test\"]\n#    branches: [master]\n#    tags: ['0.*']\n#    types: [completed]\n\n  # Or a new tagged release\n  release:\n    types: [published, edited]\n\n  push:\n    branches:\n      - master\n\njobs:\n  metadata:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Show metadata\n      run: |\n        echo SHA ${{ github.sha }}\n        echo github.ref:  ${{ github.ref }}\n        echo github_ref: $GITHUB_REF\n        echo Event name: ${{ github.event_name }}\n        echo Ref ${{ github.ref }}\n        echo c: ${{ github.event.workflow_run.conclusion }}\n        echo r: ${{ github.event.workflow_run }}\n        echo tname: \"${{ github.event.release.tag_name }}\"\n        echo headbranch: -${{ github.event.workflow_run.head_branch }}-\n        set\n\n  build-push-containers:\n    runs-on: ubuntu-latest\n    # If the testing workflow has a success, then we build to :latest\n    # Or if we are in a tagged release scenario.\n    if: ${{ github.event.workflow_run.conclusion == 'success' }} || ${{ github.event.release.tag_name }} != ''\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Python 3.11\n        uses: actions/setup-python@v6\n        with:\n          python-version: 3.11\n\n      - name: Cache pip packages\n        uses: actions/cache@v5\n        with:\n          path: ~/.cache/pip\n          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}\n          restore-keys: |\n            ${{ runner.os }}-pip-\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install flake8 pytest\n          if [ -f requirements.txt ]; then pip install -r requirements.txt; fi\n\n      - name: Create release metadata\n        run: |\n          # COPY'ed by Dockerfile into changedetectionio/ of the image, then read by the server in store.py\n          echo ${{ github.sha }} > changedetectionio/source.txt\n          echo ${{ github.ref }} > changedetectionio/tag.txt\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n        with:\n          image: tonistiigi/binfmt:latest\n          platforms: all\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Docker Hub Container Registry\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Set up Docker Buildx\n        id: buildx\n        uses: docker/setup-buildx-action@v4\n        with:\n          install: true\n          version: latest\n          driver-opts: image=moby/buildkit:master\n\n      # master branch -> :dev container tag\n      - name: Docker meta :dev\n        if: ${{ github.ref == 'refs/heads/master' && github.event_name != 'release' }}\n        uses: docker/metadata-action@v6\n        id: meta_dev\n        with:\n          images: |\n            ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io\n            ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=dev\n          labels: |\n            org.opencontainers.image.created=${{ github.event.release.published_at }}\n            org.opencontainers.image.description=Website, webpage change detection, monitoring and notifications.\n            org.opencontainers.image.documentation=https://changedetection.io\n            org.opencontainers.image.revision=${{ github.sha }}\n            org.opencontainers.image.source=https://github.com/dgtlmoon/changedetection.io\n            org.opencontainers.image.title=changedetection.io\n            org.opencontainers.image.url=https://changedetection.io\n\n      - name: Build and push :dev\n        id: docker_build\n        if: ${{ github.ref == 'refs/heads/master' && github.event_name != 'release' }}\n        uses: docker/build-push-action@v7\n        with:\n          context: ./\n          file: ./Dockerfile\n          push: true\n          tags: ${{ steps.meta_dev.outputs.tags }}\n          labels: ${{ steps.meta_dev.outputs.labels }}\n          platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n# Looks like this was disabled\n#          provenance: false\n\n      # A new tagged release is required, which builds :tag and :latest\n      - name: Debug release info\n        if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')\n        run: |\n          echo \"Release tag: ${{ github.event.release.tag_name }}\"\n          echo \"Github ref: ${{ github.ref }}\"\n          echo \"Github ref name: ${{ github.ref_name }}\"\n\n      - name: Docker meta :tag\n        if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')\n        uses: docker/metadata-action@v6\n        id: meta\n        with:\n            images: |\n                ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io\n                ghcr.io/dgtlmoon/changedetection.io\n            tags: |\n                type=semver,pattern={{version}},value=${{ github.event.release.tag_name }}\n                type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }}\n                type=semver,pattern={{major}},value=${{ github.event.release.tag_name }}\n                type=raw,value=latest\n            labels: |\n              org.opencontainers.image.created=${{ github.event.release.published_at }}\n              org.opencontainers.image.description=Website, webpage change detection, monitoring and notifications.\n              org.opencontainers.image.documentation=https://changedetection.io\n              org.opencontainers.image.revision=${{ github.sha }}\n              org.opencontainers.image.source=https://github.com/dgtlmoon/changedetection.io\n              org.opencontainers.image.title=changedetection.io\n              org.opencontainers.image.url=https://changedetection.io\n              org.opencontainers.image.version=${{ github.event.release.tag_name }}\n\n      - name: Build and push :tag\n        id: docker_build_tag_release\n        if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.')\n        uses: docker/build-push-action@v7\n        with:\n          context: ./\n          file: ./Dockerfile\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n# Looks like this was disabled\n#          provenance: false\n\n      - name: Image digest\n        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 }}\n\n"
  },
  {
    "path": ".github/workflows/pypi-release.yml",
    "content": "name: Publish Python 🐍distribution 📦 to PyPI and TestPyPI\n\non: push\njobs:\n  build:\n    name: Build distribution 📦\n    runs-on: ubuntu-latest\n\n    steps:\n    - uses: actions/checkout@v6\n    - name: Set up Python\n      uses: actions/setup-python@v6\n      with:\n        python-version: \"3.11\"\n    - name: Install pypa/build\n      run: >-\n        python3 -m\n        pip install\n        build\n        --user\n    - name: Build a binary wheel and a source tarball\n      run: python3 -m build\n    - name: Store the distribution packages\n      uses: actions/upload-artifact@v7\n      with:\n        name: python-package-distributions\n        path: dist/\n\n\n  test-pypi-package:\n    name: Test the built package works basically.\n    runs-on: ubuntu-latest\n    needs:\n    - build\n    steps:\n    - name: Download all the dists\n      uses: actions/download-artifact@v8\n      with:\n        name: python-package-distributions\n        path: dist/\n    - name: Set up Python 3.11\n      uses: actions/setup-python@v6\n      with:\n        python-version: '3.11'\n\n    - name: Test that the basic pip built package runs without error\n      run: |\n        set -ex\n        ls -alR \n        \n        # Install the first wheel found in dist/\n        WHEEL=$(find dist -type f -name \"*.whl\" -print -quit)\n        echo Installing $WHEEL\n        python3 -m pip install --upgrade pip\n        python3 -m pip install \"$WHEEL\"\n        changedetection.io -d /tmp -p 10000 &\n        \n        sleep 3\n        curl --retry-connrefused --retry 6 http://127.0.0.1:10000/static/styles/pure-min.css >/dev/null\n        curl --retry-connrefused --retry 6 http://127.0.0.1:10000/ >/dev/null\n        \n        # --- API test ---\n        # This also means that the docs/api-spec.yml was shipped and could be read\n        test -f /tmp/changedetection.json\n        API_KEY=$(jq -r '.. | .api_access_token? // empty' /tmp/changedetection.json)\n        echo Test API KEY is $API_KEY\n        curl -X POST \"http://127.0.0.1:10000/api/v1/watch\" \\\n          -H \"x-api-key: ${API_KEY}\" \\\n          -H \"Content-Type: application/json\" \\\n          --show-error --fail \\\n          --retry 6 --retry-delay 1 --retry-connrefused \\\n          -d '{\n            \"url\": \"https://example.com\",\n            \"title\": \"Example Site Monitor\",\n            \"time_between_check\": { \"hours\": 1 }\n          }'\n          \n        killall changedetection.io\n\n\n  publish-to-pypi:\n    name: >-\n      Publish Python 🐍 distribution 📦 to PyPI\n    if: startsWith(github.ref, 'refs/tags/')  # only publish to PyPI on tag pushes\n    needs:\n    - test-pypi-package\n    runs-on: ubuntu-latest\n    environment:\n      name: release\n      url: https://pypi.org/p/changedetection.io\n    permissions:\n      id-token: write  # IMPORTANT: mandatory for trusted publishing\n\n    steps:\n    - name: Download all the dists\n      uses: actions/download-artifact@v8\n      with:\n        name: python-package-distributions\n        path: dist/\n    - name: Publish distribution 📦 to PyPI\n      uses: pypa/gh-action-pypi-publish@release/v1\n"
  },
  {
    "path": ".github/workflows/test-container-build.yml",
    "content": "name: ChangeDetection.io Container Build Test\n\n# Triggers the workflow on push or pull request events\n\n# This line doesnt work, even tho it is the documented one\n#on: [push, pull_request]\n\non:\n  push:\n    paths:\n      - requirements.txt\n      - Dockerfile\n      - .github/workflows/*\n      - .github/test/Dockerfile*\n\n  pull_request:\n    paths:\n      - requirements.txt\n      - Dockerfile\n      - .github/workflows/*\n      - .github/test/Dockerfile*\n\n  # Changes to requirements.txt packages and Dockerfile may or may not always be compatible with arm etc, so worth testing\n  # @todo: some kind of path filter for requirements.txt and Dockerfile\njobs:\n  builder:\n    name: Build ${{ matrix.platform }} (${{ matrix.dockerfile == './Dockerfile' && 'main' || 'alpine' }})\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        include:\n          # Main Dockerfile platforms\n          - platform: linux/amd64\n            dockerfile: ./Dockerfile\n          - platform: linux/arm64\n            dockerfile: ./Dockerfile\n          - platform: linux/arm/v7\n            dockerfile: ./Dockerfile\n          - platform: linux/arm/v8\n            dockerfile: ./Dockerfile\n          # Alpine Dockerfile platforms (musl via alpine check)\n          - platform: linux/amd64\n            dockerfile: ./.github/test/Dockerfile-alpine\n          - platform: linux/arm64\n            dockerfile: ./.github/test/Dockerfile-alpine\n    steps:\n        - uses: actions/checkout@v6\n        - name: Set up Python 3.11\n          uses: actions/setup-python@v6\n          with:\n            python-version: 3.11\n\n        - name: Cache pip packages\n          uses: actions/cache@v5\n          with:\n            path: ~/.cache/pip\n            key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}\n            restore-keys: |\n              ${{ runner.os }}-pip-\n\n        # Just test that the build works, some libraries won't compile on ARM/rPi etc\n        - name: Set up QEMU\n          uses: docker/setup-qemu-action@v4\n          with:\n            image: tonistiigi/binfmt:latest\n            platforms: all\n\n        - name: Set up Docker Buildx\n          id: buildx\n          uses: docker/setup-buildx-action@v4\n          with:\n            install: true\n            version: latest\n            driver-opts: image=moby/buildkit:master\n\n        - name: Test that the docker containers can build (${{ matrix.platform }} - ${{ matrix.dockerfile }})\n          id: docker_build\n          uses: docker/build-push-action@v7\n          # https://github.com/docker/build-push-action#customizing\n          with:\n            context: ./\n            file: ${{ matrix.dockerfile }}\n            platforms: ${{ matrix.platform }}\n            cache-from: type=gha\n            cache-to: type=gha,mode=max\n\n"
  },
  {
    "path": ".github/workflows/test-only.yml",
    "content": "name: ChangeDetection.io App Test\n\n# Triggers the workflow on push or pull request events\non: [push, pull_request]\n\njobs:\n  lint-code:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Lint with Ruff\n        run: |\n          pip install ruff\n          # Check for syntax errors and undefined names\n          ruff check . --select E9,F63,F7,F82\n          # Complete check with errors treated as warnings\n          ruff check . --exit-zero\n      - name: Validate OpenAPI spec\n        run: |\n          pip install openapi-spec-validator\n          python3 -c \"from openapi_spec_validator import validate_spec; import yaml; validate_spec(yaml.safe_load(open('docs/api-spec.yaml')))\"\n\n  test-application-3-10:\n    # Only run on push to master (including PR merges)\n    if: github.event_name == 'push' && github.ref == 'refs/heads/master'\n    needs: lint-code\n    uses: ./.github/workflows/test-stack-reusable-workflow.yml\n    with:\n      python-version: '3.10'\n\n\n  test-application-3-11:\n    # Always run\n    needs: lint-code\n    uses: ./.github/workflows/test-stack-reusable-workflow.yml\n    with:\n      python-version: '3.11'\n\n  test-application-3-12:\n    # Only run on push to master (including PR merges)\n    if: github.event_name == 'push' && github.ref == 'refs/heads/master'\n    needs: lint-code\n    uses: ./.github/workflows/test-stack-reusable-workflow.yml\n    with:\n      python-version: '3.12'\n      skip-pypuppeteer: true\n\n  test-application-3-13:\n    # Only run on push to master (including PR merges)\n    if: github.event_name == 'push' && github.ref == 'refs/heads/master'\n    needs: lint-code\n    uses: ./.github/workflows/test-stack-reusable-workflow.yml\n    with:\n      python-version: '3.13'\n      skip-pypuppeteer: true\n\n\n  test-application-3-14:\n    #if: github.event_name == 'push' && github.ref == 'refs/heads/master'\n    needs: lint-code\n    uses: ./.github/workflows/test-stack-reusable-workflow.yml\n    with:\n      python-version: '3.14'\n      skip-pypuppeteer: false\n"
  },
  {
    "path": ".github/workflows/test-stack-reusable-workflow.yml",
    "content": "name: ChangeDetection.io App Test\n\non:\n  workflow_call:\n    inputs:\n      python-version:\n        description: 'Python version to use'\n        required: true\n        type: string\n        default: '3.11'\n      skip-pypuppeteer:\n        description: 'Skip PyPuppeteer (not supported in 3.11/3.12)'\n        required: false\n        type: boolean\n        default: false\n\njobs:\n  # Build the Docker image once and share it with all test jobs\n  build:\n    runs-on: ubuntu-latest\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Set up Python ${{ env.PYTHON_VERSION }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Cache pip packages\n        uses: actions/cache@v5\n        with:\n          path: ~/.cache/pip\n          key: ${{ runner.os }}-pip-py${{ env.PYTHON_VERSION }}-${{ hashFiles('requirements.txt') }}\n          restore-keys: |\n            ${{ runner.os }}-pip-py${{ env.PYTHON_VERSION }}-\n            ${{ runner.os }}-pip-\n\n      - name: Get current date for cache key\n        id: date\n        run: echo \"date=$(date +'%Y-%m-%d')\" >> $GITHUB_OUTPUT\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Build changedetection.io container for testing under Python ${{ env.PYTHON_VERSION }}\n        uses: docker/build-push-action@v7\n        with:\n          context: ./\n          file: ./Dockerfile\n          build-args: |\n            PYTHON_VERSION=${{ env.PYTHON_VERSION }}\n            LOGGER_LEVEL=TRACE\n          tags: test-changedetectionio\n          load: true\n          cache-from: type=gha,scope=build-${{ github.ref_name }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('requirements.txt', 'Dockerfile') }}-${{ steps.date.outputs.date }}\n          cache-to: type=gha,mode=max,scope=build-${{ github.ref_name }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('requirements.txt', 'Dockerfile') }}-${{ steps.date.outputs.date }}\n\n      - name: Verify build\n        run: |\n          echo \"---- Built for Python ${{ env.PYTHON_VERSION }} -----\"\n          docker run test-changedetectionio bash -c 'pip list'\n\n      - name: We should be Python ${{ env.PYTHON_VERSION }} ...\n        run: |\n          docker run test-changedetectionio bash -c 'python3 --version'\n\n      - name: Save Docker image\n        run: |\n          docker save test-changedetectionio -o /tmp/test-changedetectionio.tar\n\n      - name: Upload Docker image artifact\n        uses: actions/upload-artifact@v7\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp/test-changedetectionio.tar\n          retention-days: 1\n\n  # Unit tests (lightweight, no ancillary services needed)\n  unit-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Run Unit Tests\n        run: |\n          docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'\n          docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'\n          docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'\n          docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'\n          docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'         \n\n  # Basic pytest tests with ancillary services\n  basic-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 25\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Test built container with Pytest\n        run: |\n          docker network inspect changedet-network >/dev/null 2>&1 || docker network create changedet-network\n          docker run --name test-cdio-basic-tests --network changedet-network test-changedetectionio bash -c 'cd changedetectionio && ./run_basic_tests.sh'\n\n      - name: Test CLI options\n        run: |\n          docker network inspect changedet-network >/dev/null 2>&1 || docker network create changedet-network\n          docker run --name test-cdio-cli-opts --network changedet-network test-changedetectionio bash -c 'changedetectionio/test_cli_opts.sh' &> cli-opts-output.txt\n          echo \"=== CLI Options Test Output ===\"\n          cat cli-opts-output.txt\n\n      - name: CLI Memory Test\n        run: |\n          echo \"=== Checking CLI batch mode memory usage ===\"\n          # Extract RSS memory value from output\n          RSS_MB=$(grep -oP \"Memory consumption before worker shutdown: RSS=\\K[\\d.]+\" cli-opts-output.txt | head -1 || echo \"0\")\n          echo \"RSS Memory: ${RSS_MB} MB\"\n\n          # Check if RSS is less than 100MB\n          if [ -n \"$RSS_MB\" ]; then\n            if (( $(echo \"$RSS_MB < 100\" | bc -l) )); then\n              echo \"✓ Memory usage is acceptable: ${RSS_MB} MB < 100 MB\"\n            else\n              echo \"✗ Memory usage too high: ${RSS_MB} MB >= 100 MB\"\n              exit 1\n            fi\n          else\n            echo \"⚠ Could not extract memory usage, skipping check\"\n          fi\n\n      - name: Extract memory report and logs\n        if: always()\n        uses: ./.github/actions/extract-memory-report\n        with:\n          container-name: test-cdio-basic-tests\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Store test artifacts\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}\n          path: output-logs\n\n      - name: Store CLI test output\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: test-cdio-cli-opts-output-py${{ env.PYTHON_VERSION }}\n          path: cli-opts-output.txt\n\n  # Playwright tests\n  playwright-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Spin up ancillary services\n        run: |\n          docker network create changedet-network\n          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\n          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\n\n      - name: Playwright - Specific tests in built container\n        run: |\n          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'\n          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'\n          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'\n          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'\n\n      - name: Playwright - Headers and requests\n        run: |\n          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 .'\n\n      - name: Playwright - Restock detection\n        run: |\n          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'\n\n  # Pyppeteer tests\n  pyppeteer-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    if: ${{ inputs.skip-pypuppeteer == false }}\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Spin up ancillary services\n        run: |\n          docker network create changedet-network\n          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\n\n      - name: Pyppeteer - Specific tests in built container\n        run: |\n          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'\n          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'\n          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'\n          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'\n\n      - name: Pyppeteer - Headers and requests checks\n        run: |\n          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'\n\n      - name: Pyppeteer - Restock detection\n        run: |\n          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'\n\n  # Selenium tests\n  selenium-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Spin up ancillary services\n        run: |\n          docker network create changedet-network\n          docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size=\"2g\" selenium/standalone-chrome:4\n          sleep 3\n\n      - name: Specific tests for headers and requests checks with Selenium\n        run: |\n\n          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'\n\n      - name: Specific tests in built container for Selenium\n        run: |\n          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'\n\n\n  # SMTP tests\n  smtp-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Spin up SMTP test server\n        run: |\n          docker network create changedet-network\n          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'\n\n      - name: Test SMTP notification mime types\n        run: |\n          docker run --rm --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest tests/smtp/test_notification_smtp.py'\n\n  nginx-reverse-proxy:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Spin up services\n        run: |\n          docker network create changedet-network\n\n          # Start changedetection.io container with X-Forwarded headers support\n          docker run --name changedet-app --hostname changedet-app --network changedet-network \\\n            -e USE_X_SETTINGS=true \\\n            -d test-changedetectionio\n          sleep 3\n\n      - name: Start nginx reverse proxy\n        run: |\n          # Start nginx with our test configuration\n          docker run --name nginx-proxy --network changedet-network -d -p 8080:80 --rm \\\n            -v ${{ github.workspace }}/.github/nginx-reverse-proxy-test.conf:/etc/nginx/conf.d/default.conf:ro \\\n            nginx:alpine\n          sleep 2\n\n      - name: Test reverse proxy - root path\n        run: |\n          echo \"=== Testing nginx reverse proxy at root path ===\"\n          curl --retry-connrefused --retry 6 -s http://localhost:8080/ > /tmp/nginx-test-root.html\n\n          # Check for changedetection.io UI elements\n          if grep -q \"checkbox-uuid\" /tmp/nginx-test-root.html; then\n            echo \"✓ Found checkbox-uuid in response\"\n          else\n            echo \"ERROR: checkbox-uuid not found in response\"\n            cat /tmp/nginx-test-root.html\n            exit 1\n          fi\n\n          # Check for watchlist content\n          if grep -q -i \"watch\" /tmp/nginx-test-root.html; then\n            echo \"✓ Found watch/watchlist content in response\"\n          else\n            echo \"ERROR: watchlist content not found\"\n            cat /tmp/nginx-test-root.html\n            exit 1\n          fi\n\n          echo \"✓ Root path reverse proxy working correctly\"\n\n      - name: Test reverse proxy - subpath with X-Forwarded-Prefix\n        run: |\n          echo \"=== Testing nginx reverse proxy at subpath /changedet-sub/ ===\"\n          curl --retry-connrefused --retry 6 -s http://localhost:8080/changedet-sub/ > /tmp/nginx-test-subpath.html\n\n          # Check for changedetection.io UI elements\n          if grep -q \"checkbox-uuid\" /tmp/nginx-test-subpath.html; then\n            echo \"✓ Found checkbox-uuid in subpath response\"\n          else\n            echo \"ERROR: checkbox-uuid not found in subpath response\"\n            cat /tmp/nginx-test-subpath.html\n            exit 1\n          fi\n\n          echo \"✓ Subpath reverse proxy working correctly\"\n\n      - name: Test API through reverse proxy subpath\n        run: |\n          echo \"=== Testing API endpoints through nginx subpath /changedet-sub/ ===\"\n\n          # Extract API key from the changedetection.io datastore\n          API_KEY=$(docker exec changedet-app cat /datastore/changedetection.json | grep -o '\"api_access_token\": *\"[^\"]*\"' | cut -d'\"' -f4)\n\n          if [ -z \"$API_KEY\" ]; then\n            echo \"ERROR: Could not extract API key from datastore\"\n            docker exec changedet-app cat /datastore/changedetection.json\n            exit 1\n          fi\n\n          echo \"✓ Extracted API key: ${API_KEY:0:8}...\"\n\n          # Create a watch via API through nginx proxy subpath\n          echo \"Creating watch via POST to /changedet-sub/api/v1/watch\"\n          RESPONSE=$(curl -s -w \"\\n%{http_code}\" -X POST \"http://localhost:8080/changedet-sub/api/v1/watch\" \\\n            -H \"x-api-key: ${API_KEY}\" \\\n            -H \"Content-Type: application/json\" \\\n            -d '{\n              \"url\": \"https://example.com/test-nginx-proxy\",\n              \"tag\": \"nginx-test\"\n            }')\n\n          HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n          BODY=$(echo \"$RESPONSE\" | head -n-1)\n\n          if [ \"$HTTP_CODE\" != \"201\" ]; then\n            echo \"ERROR: Expected HTTP 201, got $HTTP_CODE\"\n            echo \"Response: $BODY\"\n            exit 1\n          fi\n\n          echo \"✓ Watch created successfully (HTTP 201)\"\n\n          # Extract the watch UUID from response\n          WATCH_UUID=$(echo \"$BODY\" | grep -o '\"uuid\": *\"[^\"]*\"' | cut -d'\"' -f4)\n          echo \"✓ Watch UUID: $WATCH_UUID\"\n\n          # Update the watch via PUT through nginx proxy subpath\n          echo \"Updating watch via PUT to /changedet-sub/api/v1/watch/${WATCH_UUID}\"\n          RESPONSE=$(curl -s -w \"\\n%{http_code}\" -X PUT \"http://localhost:8080/changedet-sub/api/v1/watch/${WATCH_UUID}\" \\\n            -H \"x-api-key: ${API_KEY}\" \\\n            -H \"Content-Type: application/json\" \\\n            -d '{\n              \"paused\": true\n            }')\n\n          HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n          BODY=$(echo \"$RESPONSE\" | head -n-1)\n\n          if [ \"$HTTP_CODE\" != \"200\" ]; then\n            echo \"ERROR: Expected HTTP 200, got $HTTP_CODE\"\n            echo \"Response: $BODY\"\n            exit 1\n          fi\n\n          if echo \"$BODY\" | grep -q 'OK'; then\n            echo \"✓ Watch updated successfully (HTTP 200, response: OK)\"\n          else\n            echo \"ERROR: Expected response 'OK', got: $BODY\"\n            echo \"Response: $BODY\"\n            exit 1\n          fi\n\n          # Verify the watch is paused via GET\n          echo \"Verifying watch is paused via GET\"\n          RESPONSE=$(curl -s \"http://localhost:8080/changedet-sub/api/v1/watch/${WATCH_UUID}\" \\\n            -H \"x-api-key: ${API_KEY}\")\n\n          if echo \"$RESPONSE\" | grep -q '\"paused\": *true'; then\n            echo \"✓ Watch is paused as expected\"\n          else\n            echo \"ERROR: Watch paused state not confirmed\"\n            echo \"Response: $RESPONSE\"\n            exit 1\n          fi\n\n          echo \"✓ API tests through nginx subpath completed successfully\"\n\n      - name: Cleanup nginx test\n        if: always()\n        run: |\n          docker logs nginx-proxy || true\n          docker logs changedet-app || true\n          docker stop nginx-proxy changedet-app || true\n          docker rm nginx-proxy changedet-app || true\n\n\n\n  # Proxy tests\n  proxy-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Spin up services\n        run: |\n          docker network create changedet-network\n          docker run --network changedet-network -d --hostname selenium -p 4444:4444 --rm --shm-size=\"2g\" selenium/standalone-chrome:4\n          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\n          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\n\n      - name: Test proxy Squid style interaction\n        run: |\n          cd changedetectionio\n          ./run_proxy_tests.sh\n          docker ps\n          cd ..\n\n      - name: Test proxy SOCKS5 style interaction\n        run: |\n          cd changedetectionio\n          ./run_socks_proxy_tests.sh\n          cd ..\n\n  # Custom browser URL tests\n  custom-browser-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Spin up ancillary services\n        run: |\n          docker network create changedet-network\n          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\n          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\n\n      - name: Test custom browser URL\n        run: |\n          cd changedetectionio\n          ./run_custom_browser_url_tests.sh\n\n  processor-plugin-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 20\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Basic processor plugin registration and checks\n        run: |\n          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'\n\n  # Container startup tests\n  container-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Test container starts+runs basically without error\n        run: |\n          docker run --name test-changedetectionio -p 5556:5000 -d test-changedetectionio\n          sleep 3\n          curl --retry-connrefused --retry 6 -s http://localhost:5556 |grep -q checkbox-uuid\n          curl --retry-connrefused --retry 6 -s -g -6 \"http://[::1]:5556\"|grep -q checkbox-uuid\n          docker logs test-changedetectionio 2>/dev/null | grep 'TRACE log is enabled' || exit 1\n          docker logs test-changedetectionio 2>/dev/null | grep 'DEBUG' || exit 1\n          docker kill test-changedetectionio\n\n      - name: Test HTTPS SSL mode\n        run: |\n          openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -nodes -subj \"/CN=localhost\"\n          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\n          sleep 3\n          curl --retry-connrefused --retry 6 -k https://localhost:5000 -v|grep -q checkbox-uuid\n          docker kill test-changedetectionio-ssl\n\n      - name: Test IPv6 Mode\n        run: |\n          docker run --name test-changedetectionio-ipv6 --rm -p 5000:5000 -e LISTEN_HOST=:: -d test-changedetectionio\n          sleep 3\n          curl --retry-connrefused --retry 6 http://[::1]:5000 -v|grep -q checkbox-uuid\n          docker kill test-changedetectionio-ipv6\n\n  # Signal tests\n  signal-tests:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 10\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Download Docker image artifact\n        uses: actions/download-artifact@v8\n        with:\n          name: test-changedetectionio-${{ env.PYTHON_VERSION }}\n          path: /tmp\n\n      - name: Load Docker image\n        run: |\n          docker load -i /tmp/test-changedetectionio.tar\n\n      - name: Test SIGTERM and SIGINT signal shutdown\n        run: |\n          echo SIGINT Shutdown request test\n          docker run --name sig-test -d test-changedetectionio\n          sleep 3\n          echo \">>> Sending SIGINT to sig-test container\"\n          docker kill --signal=SIGINT sig-test\n          sleep 3\n          docker ps\n          docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGINT' || exit 1\n          test -z \"`docker ps|grep sig-test`\"\n          if [ $? -ne 0 ]; then\n            echo \"Looks like container was running when it shouldnt be\"\n            docker ps\n            exit 1\n          fi\n          docker rm sig-test\n\n          echo SIGTERM Shutdown request test\n          docker run --name sig-test -d test-changedetectionio\n          sleep 3\n          echo \">>> Sending SIGTERM to sig-test container\"\n          docker kill --signal=SIGTERM sig-test\n          sleep 3\n          docker ps\n          docker logs sig-test 2>&1 | grep 'Shutdown: Got Signal - SIGTERM' || exit 1\n          test -z \"`docker ps|grep sig-test`\"\n          if [ $? -ne 0 ]; then\n            echo \"Looks like container was running when it shouldnt be\"\n            docker ps\n            exit 1\n          fi\n          docker rm sig-test\n\n  # Upgrade path test\n  upgrade-path-test:\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 25\n    env:\n      PYTHON_VERSION: ${{ inputs.python-version }}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          fetch-depth: 0  # Fetch all history and tags for upgrade testing\n\n      - name: Set up Python ${{ env.PYTHON_VERSION }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Check upgrade works without error\n        run: |\n          echo \"=== Testing upgrade path from 0.49.1 to ${{ github.ref_name }} (${{ github.sha }}) ===\"\n          sudo apt-get update && sudo apt-get install -y --no-install-recommends \\\n              g++ \\\n              gcc \\\n              libc-dev \\\n              libffi-dev \\\n              libjpeg-dev \\\n              libssl-dev \\\n              libxslt-dev \\\n              make \\\n              patch \\\n              pkg-config \\\n              zlib1g-dev\n          \n          # Checkout old version and create datastore\n          git checkout 0.49.1\n          python3 -m venv .venv\n          source .venv/bin/activate\n          pip install -r requirements.txt\n          pip install 'pyOpenSSL>=23.2.0'\n\n          echo \"=== Running version 0.49.1 to create datastore ===\"\n          ALLOW_IANA_RESTRICTED_ADDRESSES=true python3 ./changedetection.py -C -d /tmp/data &\n          APP_PID=$!\n\n          # Wait for app to be ready\n          echo \"Waiting for 0.49.1 to be ready...\"\n          sleep 6\n\n          # Extract API key from datastore (0.49.1 uses url-watches.json)\n          API_KEY=$(jq -r '.settings.application.api_access_token // empty' /tmp/data/url-watches.json)\n          echo \"API Key: ${API_KEY:0:8}...\"\n\n          # Create a watch with tag \"github-group-test\" via API\n          echo \"Creating test watch with tag via API...\"\n          curl -X POST \"http://127.0.0.1:5000/api/v1/watch\" \\\n            -H \"x-api-key: ${API_KEY}\" \\\n            -H \"Content-Type: application/json\" \\\n            --show-error --fail \\\n            --retry 6 --retry-delay 1 --retry-connrefused \\\n            -d '{\n              \"url\": \"https://example.com/upgrade-test\",\n              \"tag\": \"github-group-test\"\n            }'\n\n          echo \"✓ Created watch with tag 'github-group-test'\"\n\n          # Create a specific test URL watch\n          echo \"Creating test URL watch via API...\"\n          curl -X POST \"http://127.0.0.1:5000/api/v1/watch\" \\\n            -H \"x-api-key: ${API_KEY}\" \\\n            -H \"Content-Type: application/json\" \\\n            --show-error --fail \\\n            -d '{\n              \"url\": \"http://localhost/test.txt\"\n            }'\n\n          echo \"✓ Created watch for 'http://localhost/test.txt' in version 0.49.1\"\n\n          # Stop the old version gracefully\n          kill $APP_PID\n          wait $APP_PID || true\n          echo \"✓ Version 0.49.1 stopped\"\n\n          # Upgrade to current version (use commit SHA since we're in detached HEAD)\n          echo \"Upgrading to commit ${{ github.sha }}\"\n          git checkout ${{ github.sha }}\n          pip install -r requirements.txt\n\n          echo \"=== Running current version (commit ${{ github.sha }}) with old datastore (testing mode) ===\"\n          ALLOW_IANA_RESTRICTED_ADDRESSES=true TESTING_SHUTDOWN_AFTER_DATASTORE_LOAD=1 python3 ./changedetection.py -d /tmp/data > /tmp/upgrade-test.log 2>&1\n\n          echo \"=== Upgrade test output ===\"\n          cat /tmp/upgrade-test.log\n          echo \"✓ Datastore upgraded successfully\"\n\n          # Now start the current version normally to verify the tag survived\n          echo \"=== Starting current version to verify tag exists after upgrade ===\"\n          ALLOW_IANA_RESTRICTED_ADDRESSES=true timeout 20 python3 ./changedetection.py -d /tmp/data > /tmp/ui-test.log 2>&1 &\n          APP_PID=$!\n\n          # Wait for app to be ready and fetch UI\n          echo \"Waiting for current version to be ready...\"\n          sleep 5\n          curl --retry 6 --retry-delay 1 --retry-connrefused --silent http://127.0.0.1:5000 > /tmp/ui-output.html\n\n          # Verify tag exists in UI\n          if grep -q \"github-group-test\" /tmp/ui-output.html; then\n            echo \"✓ Tag 'github-group-test' found in UI after upgrade\"\n          else\n            echo \"ERROR: Tag 'github-group-test' not found in UI after upgrade\"\n            echo \"=== UI Output ===\"\n            cat /tmp/ui-output.html\n            echo \"=== App Log ===\"\n            cat /tmp/ui-test.log\n            kill $APP_PID || true\n            exit 1\n          fi\n\n          # Verify test URL exists in UI\n          if grep -q \"http://localhost/test.txt\" /tmp/ui-output.html; then\n            echo \"✓ Watch URL 'http://localhost/test.txt' found in UI after upgrade\"\n          else\n            echo \"ERROR: Watch URL 'http://localhost/test.txt' not found in UI after upgrade\"\n            echo \"=== UI Output ===\"\n            cat /tmp/ui-output.html\n            echo \"=== App Log ===\"\n            cat /tmp/ui-test.log\n            kill $APP_PID || true\n            exit 1\n          fi\n\n          # Cleanup\n          kill $APP_PID || true\n          wait $APP_PID || true\n\n          echo \"\"\n          echo \"✓✓✓ Upgrade test passed: 0.49.1 → ${{ github.ref_name }} ✓✓✓\"\n          echo \"    - Commit: ${{ github.sha }}\"\n          echo \"    - Datastore migrated successfully\"\n          echo \"    - Tag 'github-group-test' survived upgrade\"\n          echo \"    - Watch URL 'http://localhost/test.txt' survived upgrade\"\n\n          echo \"✓ Upgrade test passed: 0.49.1 → ${{ github.ref_name }}\"\n\n      - name: Upload upgrade test logs\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: upgrade-test-logs-py${{ env.PYTHON_VERSION }}\n          path: /tmp/upgrade-test.log\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n**/__pycache__\n**/*.py[cod]\n\n# Caches\n.mypy_cache/\n.pytest_cache/\n.ruff_cache/\n\n# Distribution / packaging\nbuild/\ndist/\n*.egg-info*\n\n# Virtual environment\n.env\n.venv/\nvenv/\n.python-version\n\n# IDEs\n.idea\n.vscode/settings.json\n*~\n\n# Datastore files\ndatastore/\ntest-datastore/\n\n# Memory consumption log\ntest-memory.log\ntests/logs/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.11.2\n    hooks:\n      # Lint (and apply safe fixes)\n      - id: ruff\n        args: [--fix]\n      # Fomrat\n      - id: ruff-format\n"
  },
  {
    "path": ".ruff.toml",
    "content": "# Minimum supported version\ntarget-version = \"py310\"\n\n# Formatting options\nline-length = 100\nindent-width = 4\n\nexclude = [\n    \"__pycache__\",\n    \".eggs\",\n    \".git\",\n    \".tox\",\n    \".venv\",\n    \"*.egg-info\",\n    \"*.pyc\",\n]\n\n[lint]\n# https://docs.astral.sh/ruff/rules/\nselect = [\n    \"B\", # flake8-bugbear\n    \"B9\",\n    \"C\", \n    \"E\", # pycodestyle\n    \"F\", # Pyflakes\n    \"I\", # isort\n    \"N\", # pep8-naming\n    \"UP\", # pyupgrade\n    \"W\", # pycodestyle\n]\nignore = [\n    \"B007\", # unused-loop-control-variable\n    \"B909\", # loop-iterator-mutation\n    \"E203\", # whitespace-before-punctuation\n    \"E266\", # multiple-leading-hashes-for-block-comment\n    \"E501\", # redundant-backslash\n    \"F403\", # undefined-local-with-import-star\n    \"N802\", # invalid-function-name\n    \"N806\", # non-lowercase-variable-in-function\n    \"N815\", # mixed-case-variable-in-class-scope\n]\n\n[lint.mccabe]\nmax-complexity = 12\n\n[format]\nindent-style = \"space\"\nquote-style = \"preserve\"\n"
  },
  {
    "path": "COMMERCIAL_LICENCE.md",
    "content": "# Generally\n\nIn any commercial activity involving 'Hosting' (as defined herein), whether in part or in full, this license must be executed and adhered to.\n\n# Commercial License Agreement\n\nThis 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.\n\n### Definition of Hosting\n\nFor 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:\n- Enabling third parties to interact with the functionality of the Program or modified version remotely through a computer network.\n- Offering a service the value of which entirely or primarily derives from the value of the Program or modified version.\n- Offering a service that accomplishes for users the primary purpose of the Program or modified version.\n\n## 1. Grant of License\nSubject 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:\n- Resell the Software as part of a service offering or as a standalone product.\n- Host the Software on a server and provide it as a hosted service (e.g., Software as a Service - SaaS).\n- 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.\n\n## 2. License Fees\nLicensee 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.\n\n## 3. Resale Conditions\nLicensee must comply with the following conditions when reselling the Software, whether the software is resold in part or full:\n- Provide end users with access to the source code under the same open-source license conditions as provided by Licensor.\n- 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.\n- Ensure end users are aware of and agree to the terms of the commercial license prior to resale.\n- Do not sublicense or transfer the Software to third parties except as part of an authorized resale activity.\n\n## 4. Hosting and Provision of Services\nLicensee 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:\n- Licensee must ensure that all hosted versions of the Software comply with the terms of this Agreement.\n- Licensee must provide Licensor with regular reports detailing the number of end users and instances of the hosted service.\n- 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.\n\n## 5. Services\nLicensor 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.\n\n## 6. Reporting and Audits\nLicensee 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.\n\n## 7. Term and Termination\nThis 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.\n\n## 8. Limitation of Liability and Disclaimer of Warranty\nExecuting 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.\n\n## 9. Governing Law\nThis Agreement shall be governed by and construed in accordance with the laws of the Czech Republic.\n\n## Contact Information\nFor commercial licensing inquiries, please contact contact@changedetection.io and dgtlmoon@gmail.com.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "Contributing is always welcome!\n\nI am no professional flask developer, if you know a better way that something can be done, please let me know!\n\nOtherwise, it's always best to PR into the `master` branch.\n\nPlease be sure that all new functionality has a matching test!\n\nUse `pytest` to validate/test, you can run the existing tests as `pytest tests/test_notification.py` for example\n"
  },
  {
    "path": "Dockerfile",
    "content": "# pip dependencies install stage\n\nARG PYTHON_VERSION=3.11\n\nFROM python:${PYTHON_VERSION}-slim-bookworm AS builder\n\n# See `cryptography` pin comment in requirements.txt\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    g++ \\\n    gcc \\\n    libc-dev \\\n    libffi-dev \\\n    libjpeg-dev \\\n    libssl-dev \\\n    libxslt-dev \\\n    make \\\n    patch \\\n    pkg-config \\\n    zlib1g-dev\n\nRUN mkdir /install\nWORKDIR /install\n\nCOPY requirements.txt /requirements.txt\n\n# Use cache mounts and multiple wheel sources for faster ARM builds\nENV PIP_CACHE_DIR=/tmp/pip-cache\n# Help Rust find OpenSSL for cryptography package compilation on ARM\nENV PKG_CONFIG_PATH=\"/usr/lib/pkgconfig:/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/lib/aarch64-linux-gnu/pkgconfig\"\nENV PKG_CONFIG_ALLOW_SYSTEM_CFLAGS=1\nENV OPENSSL_DIR=\"/usr\"\nENV OPENSSL_LIB_DIR=\"/usr/lib/arm-linux-gnueabihf\"\nENV OPENSSL_INCLUDE_DIR=\"/usr/include/openssl\"\n# Additional environment variables for cryptography Rust build\nENV CRYPTOGRAPHY_DONT_BUILD_RUST=1\n\nRUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \\\n  pip install \\\n  --prefer-binary \\\n  --extra-index-url https://www.piwheels.org/simple \\\n  --extra-index-url https://pypi.anaconda.org/ARM-software/simple \\\n  --cache-dir=/tmp/pip-cache \\\n  --target=/dependencies \\\n  -r /requirements.txt\n\n# Playwright is an alternative to Selenium\n# Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing\n# https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported)\nRUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \\\n  pip install \\\n  --prefer-binary \\\n  --cache-dir=/tmp/pip-cache \\\n  --target=/dependencies \\\n  playwright~=1.56.0 \\\n  || echo \"WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled.\"\n\n# OpenCV is optional for fast image comparison (pixelmatch is the fallback)\n# Skip on arm/v7 and arm/v8 where builds take weeks - excluded from requirements.txt\nARG TARGETPLATFORM\nRUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \\\n  case \"$TARGETPLATFORM\" in \\\n    linux/arm/v7|linux/arm/v8) \\\n      echo \"INFO: Skipping OpenCV on $TARGETPLATFORM (build takes too long), using pixelmatch fallback\" \\\n      ;; \\\n    *) \\\n      pip install \\\n        --prefer-binary \\\n        --extra-index-url https://www.piwheels.org/simple \\\n        --cache-dir=/tmp/pip-cache \\\n        --target=/dependencies \\\n        opencv-python-headless>=4.8.0.76 \\\n        || echo \"WARN: OpenCV install failed, will use pixelmatch fallback\" \\\n      ;; \\\n  esac\n\n\n# Final image stage\nFROM python:${PYTHON_VERSION}-slim-bookworm\nLABEL org.opencontainers.image.source=\"https://github.com/dgtlmoon/changedetection.io\"\nLABEL org.opencontainers.image.url=\"https://changedetection.io\"\nLABEL org.opencontainers.image.documentation=\"https://changedetection.io/tutorials\"\nLABEL org.opencontainers.image.title=\"changedetection.io\"\nLABEL org.opencontainers.image.description=\"Self-hosted web page change monitoring and notification service\"\nLABEL org.opencontainers.image.licenses=\"Apache-2.0\"\nLABEL org.opencontainers.image.vendor=\"changedetection.io\"\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    libxslt1.1 \\\n    # For presenting price amounts correctly in the restock/price detection overview\n    locales \\\n    # For pdftohtml\n    poppler-utils \\\n    # favicon type detection and other uses\n    file \\\n    zlib1g \\\n    # OpenCV dependencies for image processing\n    libglib2.0-0 \\\n    libsm6 \\\n    libxext6 \\\n    libxrender-dev \\\n    && apt-get clean && rm -rf /var/lib/apt/lists/*\n\n\n# https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops\nENV PYTHONUNBUFFERED=1\n\nRUN [ ! -d \"/datastore\" ] && mkdir /datastore\n\n# Re #80, sets SECLEVEL=1 in openssl.conf to allow monitoring sites with weak/old cipher suites\nRUN sed -i 's/^CipherString = .*/CipherString = DEFAULT@SECLEVEL=1/' /etc/ssl/openssl.cnf\n\n# Copy modules over to the final image and add their dir to PYTHONPATH\nCOPY --from=builder /dependencies /usr/local\nENV PYTHONPATH=/usr/local\n\nEXPOSE 5000\n\n# The actual flask app module\nCOPY changedetectionio /app/changedetectionio\n\n# Compile translation files for i18n support\nRUN pybabel compile -d /app/changedetectionio/translations\n\n# Also for OpenAPI validation wrapper - needs the YML\nRUN [ ! -d \"/app/docs\" ] && mkdir /app/docs\nCOPY docs/api-spec.yaml /app/docs/api-spec.yaml\n\n# Starting wrapper\nCOPY changedetection.py /app/changedetection.py\n\n# Github Action test purpose(test-only.yml).\n# On production, it is effectively LOGGER_LEVEL=''.\nARG LOGGER_LEVEL=''\nENV LOGGER_LEVEL=\"$LOGGER_LEVEL\"\n\n# Default\nENV LC_ALL=en_US.UTF-8\n\nWORKDIR /app\n\n# Copy and set up entrypoint script for installing extra packages\nCOPY docker-entrypoint.sh /docker-entrypoint.sh\nRUN chmod +x /docker-entrypoint.sh\n\n# Set entrypoint to handle EXTRA_PACKAGES env var\nENTRYPOINT [\"/docker-entrypoint.sh\"]\n\n# Default command (can be overridden in docker-compose.yml)\nCMD [\"python\", \"./changedetection.py\", \"-d\", \"/datastore\"]\n\n\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2025 Web Technologies s.r.o.\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "recursive-include changedetectionio/api *\ninclude docs/api-spec.yaml\nrecursive-include changedetectionio/blueprint *\nrecursive-include changedetectionio/conditions *\nrecursive-include changedetectionio/content_fetchers *\nrecursive-include changedetectionio/jinja2_custom *\nrecursive-include changedetectionio/model *\nrecursive-include changedetectionio/notification *\nrecursive-include changedetectionio/processors *\nrecursive-include changedetectionio/realtime *\nrecursive-include changedetectionio/static *\nrecursive-include changedetectionio/store *\nrecursive-include changedetectionio/templates *\nrecursive-include changedetectionio/tests *\nrecursive-include changedetectionio/translations *\nrecursive-include changedetectionio/widgets *\nprune changedetectionio/static/package-lock.json\nprune changedetectionio/static/styles/node_modules\nprune changedetectionio/static/styles/package-lock.json\ninclude changedetectionio/favicon_utils.py\ninclude changedetection.py\ninclude requirements.txt\ninclude README-pip.md\nglobal-exclude *.pyc\nglobal-exclude node_modules\nglobal-exclude venv\n\nglobal-exclude test-datastore\nglobal-exclude changedetection.io*dist-info\nglobal-exclude changedetectionio/tests/proxy_socks5/test-datastore\n"
  },
  {
    "path": "README-pip.md",
    "content": "# Monitor website changes\n\nDetect WebPage Changes Automatically — Monitor Web Page Changes in Real Time\n\nMonitor websites for updates — get notified via Discord, Email, Slack, Telegram, Webhook and many more.\n\nDetect web page content changes and get instant alerts.\n\n\n[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\n\nIdeal for monitoring price changes, content edits, conditional changes and more.\n\n[<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)\n\n\n[**Don't have time? Try our extremely affordable subscription use our proxies and support!**](https://changedetection.io)\n\n\n\n### Target specific parts of the webpage using the Visual Selector tool.\n\nAvailable 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)\n\n[<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)\n\n### Easily see what changed, examine by word, line, or individual character.\n\n[<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)\n\n\n### Perform interactive browser steps\n\nFill in text boxes, click buttons and more, setup your changedetection scenario.\n\nUsing 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.\n\n[<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)\n\nAfter **Browser Steps** have been run, then visit the **Visual Selector** tab to refine the content you're interested in.\nRequires Playwright to be enabled.\n\n\n### Example use cases\n\n- Products and services have a change in pricing\n- _Out of stock notification_ and _Back In stock notification_\n- Monitor and track PDF file changes, know when a PDF file has text changes.\n- Governmental department updates (changes are often only on their websites)\n- New software releases, security advisories when you're not on their mailing list.\n- Festivals with changes\n- Discogs restock alerts and monitoring\n- Realestate listing changes\n- Know when your favourite whiskey is on sale, or other special deals are announced before anyone else\n- COVID related news from government websites\n- University/organisation news from their website\n- Detect and monitor changes in JSON API responses\n- JSON API monitoring and alerting\n- Changes in legal and other documents\n- Trigger API calls via notifications when text appears on a website\n- Glue together APIs using the JSON filter and JSON notifications\n- Create RSS feeds based on changes in web content\n- Monitor HTML source code for unexpected changes, strengthen your PCI compliance\n- 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)\n- Get notified when certain keywords appear in Twitter search results\n- Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords.\n- Get alerts when new job positions are open on Bamboo HR and other job platforms\n- Website defacement monitoring\n- Pokémon Card Restock Tracker / Pokémon TCG Tracker\n- RegTech - stay ahead of regulatory changes, regulatory compliance\n\n_Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_\n\n#### Key Features\n\n- Lots of trigger filters, such as \"Trigger on text\", \"Remove text by selector\", \"Ignore text\", \"Extract text\", also using regular-expressions!\n- Target elements with xPath(1.0) and CSS Selectors, Easily monitor complex JSON with JSONPath or jq\n- Switch between fast non-JS and Chrome JS based \"fetchers\"\n- Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums)\n- Easily specify how often a site should be checked\n- Execute JS before extracting text (Good for logging in, see examples in the UI!)\n- Override Request Headers, Specify `POST` or `GET` and other methods\n- Use the \"Visual Selector\" to help target specific elements\n- Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration)\n- Send a screenshot with the notification when a change is detected in the web page\n\nWe [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.\n\n[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.\n\nPlease :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/\n\n\n\n```bash\n$ pip3 install changedetection.io\n```\n\nSpecify a target for the *datastore path* with `-d` (required) and a *listening port* with `-p` (defaults to `5000`)\n\n```bash\n$ changedetection.io -d /path/to/empty/data/dir -p 5000\n```\n\n\nThen visit http://127.0.0.1:5000 , You should now be able to access the UI.\n\nSee https://changedetection.io for more information.\n"
  },
  {
    "path": "README.md",
    "content": "# Detect Website Changes Automatically — Monitor Web Page Changes in Real Time\n\nMonitor websites for updates — get notified via Discord, Email, Slack, Telegram, Webhook and many more.\n\n**Detect web page content changes and get instant alerts.**  \n\nIdeal for monitoring price changes, content edits, conditional changes and more.\n\n\n[<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)\n\n[![Release Version][release-shield]][release-link] [![Docker Pulls][docker-pulls]][docker-link] [![License][license-shield]](LICENSE.md)\n\n![changedetection.io](https://github.com/dgtlmoon/changedetection.io/actions/workflows/test-only.yml/badge.svg?branch=master)\n\n[**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!_\n\n\n- Chrome browser included.\n- Nothing to install, access via browser login after signup.\n- Super fast, no registration needed setup.\n- Get started watching and receiving website change notifications straight away.\n- See our [tutorials and how-to page for more inspiration](https://changedetection.io/tutorials) \n\n### Target specific parts of the webpage using the Visual Selector tool.\n\nAvailable 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)\n\n[<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)\n\n### Easily see what changed, examine by word, line, or individual character.\n\n[<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)\n\n\n### Perform interactive browser steps\n\nFill in text boxes, click buttons and more, setup your changedetection scenario. \n\nUsing 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.\n\n[<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)\n\nAfter **Browser Steps** have been run, then visit the **Visual Selector** tab to refine the content you're interested in.\nRequires Playwright to be enabled.\n\n### Awesome restock and price change notifications\n\nEnable 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.\n\nEasily 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!\n\n[<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)\n\nSet price change notification parameters, upper and lower price, price change percentage and more.\nAlways know when a product for sale drops in price.\n\n[<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)\n\n\n\n### Example use cases\n\n- Products and services have a change in pricing\n- _Out of stock notification_ and _Back In stock notification_\n- Monitor and track PDF file changes, know when a PDF file has text changes.\n- Governmental department updates (changes are often only on their websites)\n- New software releases, security advisories when you're not on their mailing list.\n- Festivals with changes\n- Discogs restock alerts and monitoring\n- Realestate listing changes\n- Know when your favourite whiskey is on sale, or other special deals are announced before anyone else\n- COVID related news from government websites\n- University/organisation news from their website\n- Detect and monitor changes in JSON API responses \n- JSON API monitoring and alerting\n- Changes in legal and other documents\n- Trigger API calls via notifications when text appears on a website\n- Glue together APIs using the JSON filter and JSON notifications\n- Create RSS feeds based on changes in web content\n- Monitor HTML source code for unexpected changes, strengthen your PCI compliance\n- 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)\n- Get notified when certain keywords appear in Twitter search results\n- Proactively search for jobs, get notified when companies update their careers page, search job portals for keywords.\n- Get alerts when new job positions are open on Bamboo HR and other job platforms\n- Website defacement monitoring\n- Pokémon Card Restock Tracker / Pokémon TCG Tracker\n- RegTech - stay ahead of regulatory changes, regulatory compliance\n\n_Need an actual Chrome runner with Javascript support? We support fetching via WebDriver and Playwright!</a>_\n\n#### Key Features\n\n- Lots of trigger filters, such as \"Trigger on text\", \"Remove text by selector\", \"Ignore text\", \"Extract text\", also using regular-expressions!\n- Target elements with xPath 1 and xPath 2, CSS Selectors, Easily monitor complex JSON with JSONPath or jq\n- Switch between fast non-JS and Chrome JS based \"fetchers\"\n- Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums)\n- Easily specify how often a site should be checked\n- Execute JS before extracting text (Good for logging in, see examples in the UI!)\n- Override Request Headers, Specify `POST` or `GET` and other methods\n- Use the \"Visual Selector\" to help target specific elements\n- Configurable [proxy per watch](https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration)\n- Send a screenshot with the notification when a change is detected in the web page\n\nWe [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.\n\nPlease :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/\n\n### Conditional web page changes\n\nEasily [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)\n\n<img src=\"./docs/web-page-change-conditions.png\" style=\"max-width:80%;\" alt=\"Conditional web page changes\"  title=\"Conditional web page changes\"  />\n\n### Schedule web page watches in any timezone, limit by day of week and time.\n\nEasily set a re-check schedule, for example you could limit the web page change detection to only operate during business hours.\nOr perhaps based on a foreign timezone (for example, you want to check for the latest news-headlines in a foreign country at 0900 AM),\n\n<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\"  />\n\nIncludes quick short-cut buttons to setup a schedule for **business hours only**, or **weekends**.\n\n### We have a Chrome extension!\n\nEasily 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.\n\n[<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)\n\n[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) ) \n\n## Installation\n\n### Docker\n\nWith Docker composer, just clone this repository and..\n\n```bash\n$ docker compose up -d\n```\n\nDocker standalone\n```bash\n$ docker run -d --restart always -p \"127.0.0.1:5000:5000\" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io\n```\n\n`:latest` tag is our latest stable release, `:dev` tag is our bleeding edge `master` branch.\n\nAlternative docker repository over at ghcr - [ghcr.io/dgtlmoon/changedetection.io](https://ghcr.io/dgtlmoon/changedetection.io)\n\n### Windows\n\nSee the install instructions at the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Microsoft-Windows\n\n### Python Pip\n\nCheck out our pypi page https://pypi.org/project/changedetection.io/\n\n```bash\n$ pip3 install changedetection.io\n$ changedetection.io -d /path/to/empty/data/dir -p 5000\n```\n\nThen visit http://127.0.0.1:5000 , You should now be able to access the UI.\n\n_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!_\n\n## Updating changedetection.io\n\n### Docker\n```\ndocker pull dgtlmoon/changedetection.io\ndocker kill $(docker ps -a -f name=changedetection.io -q)\ndocker rm $(docker ps -a -f name=changedetection.io -q)\ndocker run -d --restart always -p \"127.0.0.1:5000:5000\" -v datastore-volume:/datastore --name changedetection.io dgtlmoon/changedetection.io\n```\n\n### docker compose\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\nSee the wiki for more information https://github.com/dgtlmoon/changedetection.io/wiki\n\n## Different browser viewport sizes (mobile, desktop etc)\n\nIf 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).\n\n## Filters\n\nXPath(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. \n(We support LXML `re:test`, `re:match` and `re:replace`.)\n\n## Notifications\n\nChangeDetection.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.\nSimply set one or more notification URL's in the _[edit]_ tab of that watch.\n\nJust some examples\n\n    discord://webhook_id/webhook_token\n    flock://app_token/g:channel_id\n    gitter://token/room\n    gchat://workspace/key/token\n    msteams://TokenA/TokenB/TokenC/\n    o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail\n    rocket://user:password@hostname/#Channel\n    mailto://user:pass@example.com?to=receivingAddress@example.com\n    json://someserver.com/custom-api\n    syslog://\n \n<a href=\"https://github.com/caronc/apprise#popular-notification-services\">And everything else in this list!</a>\n\n<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\"  />\n\nNow 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!\n\n## JSON API Monitoring\n\nDetect changes and monitor data in JSON API's by using either JSONPath or jq to filter, parse, and restructure JSON as needed.\n\n![image](https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/json-filter-field-example.png)\n\nThis will re-parse the JSON and apply formatting to the text, making it super easy to monitor and detect changes in JSON API results\n\n![image](https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/json-diff-example.png)\n\n### JSONPath or jq?\n\nFor 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.\n\nOne 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.\n\nSee the wiki https://github.com/dgtlmoon/changedetection.io/wiki/JSON-Selector-Filter-help for more information and examples\n\n### Parse JSON embedded in HTML!\n\nWhen 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. \n\n```\n<html>\n...\n<script type=\"application/ld+json\">\n\n{\n   \"@context\":\"http://schema.org/\",\n   \"@type\":\"Product\",\n   \"offers\":{\n      \"@type\":\"Offer\",\n      \"availability\":\"http://schema.org/InStock\",\n      \"price\":\"3949.99\",\n      \"priceCurrency\":\"USD\",\n      \"url\":\"https://www.newegg.com/p/3D5-000D-001T1\"\n   },\n   \"description\":\"Cobratype King Cobra Hero Desktop Gaming PC\",\n   \"name\":\"Cobratype King Cobra Hero Desktop Gaming PC\",\n   \"sku\":\"3D5-000D-001T1\",\n   \"itemCondition\":\"NewCondition\"\n}\n</script>\n```  \n\n`json:$..price` or `jq:..price` would give `3949.99`, or you can extract the whole structure (use a JSONpath test website to validate with)\n\nThe application also supports notifying you that it can follow this information automatically\n\n\n## Proxy Configuration\n\nSee 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.\n\n## Raspberry Pi support?\n\nRaspberry 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)\n\n## Import support\n\nEasily [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. \n\nExcel import is recommended - that way you can better organise tags/groups of websites and other features.\n\n\n## API Support\n\nFull REST API for programmatic management of watches, tags, notifications and more. \n\n- **[Interactive API Documentation](https://changedetection.io/docs/api_v1/index.html)** - Complete API reference with live testing\n- **[OpenAPI Specification](docs/api-spec.yaml)** - Generate SDKs for any programming language\n\n## Support us\n\nDo 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.\n\n\nConsider 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!)\n\n## Commercial Support\n\nI 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\n\n\n[release-shield]: https://img.shields.io:/github/v/release/dgtlmoon/changedetection.io?style=for-the-badge\n[docker-pulls]: https://img.shields.io/docker/pulls/dgtlmoon/changedetection.io?style=for-the-badge\n[test-shield]: https://github.com/dgtlmoon/changedetection.io/actions/workflows/test-only.yml/badge.svg?branch=master\n\n[license-shield]: https://img.shields.io/github/license/dgtlmoon/changedetection.io.svg?style=for-the-badge\n[release-link]: https://github.com/dgtlmoon/changedetection.io/releases\n[docker-link]: https://hub.docker.com/r/dgtlmoon/changedetection.io\n\n## Commercial Licencing\n\nIf 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 .\n\n## Third-party licenses\n\nchangedetectionio.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)\n\n## Contributors\n\nRecognition of fantastic contributors to the project\n\n- Constantin Hong https://github.com/Constantin1489\n"
  },
  {
    "path": "babel.cfg",
    "content": "[python: **.py]\nkeywords = _:1,_l:1,gettext:1\n\n[jinja2: **/templates/**.html]\nencoding = utf-8\n"
  },
  {
    "path": "changedetection.py",
    "content": "#!/usr/bin/env python3\n\n# Only exists for direct CLI usage\n\nimport changedetectionio\n\nif __name__ == '__main__':\n    changedetectionio.main()\n"
  },
  {
    "path": "changedetectionio/.gitignore",
    "content": "test-datastore\npackage-lock.json\n"
  },
  {
    "path": "changedetectionio/PLUGIN_README.md",
    "content": "# Creating Plugins for changedetection.io\n\nThis document describes how to create plugins for changedetection.io. Plugins can be used to extend the functionality of the application in various ways.\n\n## Plugin Types\n\n### UI Stats Tab Plugins\n\nThese plugins can add content to the Stats tab in the Edit page. This is useful for adding custom statistics or visualizations about a watch.\n\n#### Creating a UI Stats Tab Plugin\n\n1. Create a Python file in a directory that will be loaded by the plugin system.\n\n2. Use the `global_hookimpl` decorator to implement the `ui_edit_stats_extras` hook:\n\n```python\nimport pluggy\nfrom loguru import logger\n\nglobal_hookimpl = pluggy.HookimplMarker(\"changedetectionio\")\n\n@global_hookimpl\ndef ui_edit_stats_extras(watch):\n    \"\"\"Add custom content to the stats tab\"\"\"\n    # Calculate or retrieve your stats\n    my_stat = calculate_something(watch)\n    \n    # Return HTML content as a string\n    html = f\"\"\"\n    <div class=\"my-plugin-stats\">\n        <h4>My Plugin Statistics</h4>\n        <p>My statistic: {my_stat}</p>\n    </div>\n    \"\"\"\n    return html\n```\n\n3. The HTML you return will be included in the Stats tab.\n\n## Plugin Loading\n\nPlugins can be loaded from:\n\n1. Built-in plugin directories in the codebase\n2. External packages using setuptools entry points\n\nTo add a new plugin directory, modify the `plugin_dirs` dictionary in `pluggy_interface.py`.\n\n## Example Plugin\n\nHere's a simple example of a plugin that adds a word count statistic to the Stats tab:\n\n```python\nimport pluggy\nfrom loguru import logger\n\nglobal_hookimpl = pluggy.HookimplMarker(\"changedetectionio\")\n\ndef count_words_in_history(watch):\n    \"\"\"Count words in the latest snapshot\"\"\"\n    try:\n        if not watch.history.keys():\n            return 0\n            \n        latest_key = list(watch.history.keys())[-1]\n        latest_content = watch.get_history_snapshot(timestamp=latest_key)\n        return len(latest_content.split())\n    except Exception as e:\n        logger.error(f\"Error counting words: {str(e)}\")\n        return 0\n\n@global_hookimpl\ndef ui_edit_stats_extras(watch):\n    \"\"\"Add word count to the Stats tab\"\"\"\n    word_count = count_words_in_history(watch)\n    \n    html = f\"\"\"\n    <div class=\"word-count-stats\">\n        <h4>Content Analysis</h4>\n        <table class=\"pure-table\">\n            <tbody>\n                <tr>\n                    <td>Word count (latest snapshot)</td>\n                    <td>{word_count}</td>\n                </tr>\n            </tbody>\n        </table>\n    </div>\n    \"\"\"\n    return html\n```\n\n## Testing Your Plugin\n\n1. Place your plugin in one of the directories scanned by the plugin system\n2. Restart changedetection.io\n3. Go to the Edit page of a watch and check the Stats tab to see your content"
  },
  {
    "path": "changedetectionio/__init__.py",
    "content": "#!/usr/bin/env python3\n\n# Read more https://github.com/dgtlmoon/changedetection.io/wiki\n# Semver means never use .01, or 00. Should be .1.\n__version__ = '0.54.6'\n\nfrom changedetectionio.strtobool import strtobool\nfrom json.decoder import JSONDecodeError\n\nfrom loguru import logger\nimport getopt\nimport logging\nimport os\nimport platform\nimport signal\nimport threading\nimport time\n\n# Eventlet completely removed - using threading mode for SocketIO\n# This provides better Python 3.12+ compatibility and eliminates eventlet/asyncio conflicts\n# Note: store and changedetection_app are imported inside main() to avoid\n# initialization before argument parsing (allows --help to work without loading everything)\n\n# ==============================================================================\n# Multiprocessing Configuration - CRITICAL for Thread Safety\n# ==============================================================================\n#\n# PROBLEM: Python 3.12+ warns about fork() with multi-threaded processes:\n#   \"This process is multi-threaded, use of fork() may lead to deadlocks\"\n#\n# WHY IT'S DANGEROUS:\n#   1. This Flask app has multiple threads (HTTP handlers, workers, SocketIO)\n#   2. fork() copies ONLY the calling thread to the child process\n#   3. BUT fork() also copies all locks/mutexes in their current state\n#   4. If another thread held a lock during fork() → child has locked lock with no owner\n#   5. Result: PERMANENT DEADLOCK if child tries to acquire that lock\n#\n# SOLUTION: Use 'spawn' instead of 'fork'\n#   - spawn starts a fresh Python interpreter (no inherited threads or locks)\n#   - Slower (~200ms vs ~1ms) but safe with multi-threaded parent\n#   - Consistent across all platforms (Windows already uses spawn by default)\n#\n# IMPLEMENTATION:\n#   1. Explicit contexts everywhere (primary protection):\n#      - playwright.py: ctx = multiprocessing.get_context('spawn')\n#      - puppeteer.py: ctx = multiprocessing.get_context('spawn')\n#      - isolated_opencv.py: ctx = multiprocessing.get_context('spawn')\n#      - isolated_libvips.py: ctx = multiprocessing.get_context('spawn')\n#\n#   2. Global default (defense-in-depth, below):\n#      - Safety net if future code forgets explicit context\n#      - Protects against third-party libraries using Process()\n#      - Costs nothing (explicit contexts always override it)\n#\n# WHY BOTH?\n#   - Explicit contexts: Clear, self-documenting, always works\n#   - Global default: Safety net for forgotten contexts or library code\n#   - If someone writes \"Process()\" instead of \"ctx.Process()\", still safe!\n#\n# See: https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods\n# ==============================================================================\n\nimport multiprocessing\nimport os\nimport sys\n\n# Limit glibc malloc arena count to prevent RSS growth from concurrent requests.\n# Default: glibc creates up to 8×CPU_cores arenas. Each concurrent thread/connection\n# can trigger a new arena, and freed memory stays mapped in those arenas as RSS forever.\n# With MALLOC_ARENA_MAX=2, at most 2 arenas are used; freed pages return to the OS faster.\n# Must be set before worker threads start; env var is read lazily by glibc on first arena creation.\nif 'MALLOC_ARENA_MAX' not in os.environ:\n    os.environ['MALLOC_ARENA_MAX'] = '2'\n    try:\n        import ctypes as _ctypes\n        _ctypes.CDLL('libc.so.6').mallopt(-8, 2)  # M_ARENA_MAX = -8\n    except Exception:\n        pass\n\n# Set spawn as global default (safety net - all our code uses explicit contexts anyway)\n# Skip in tests to avoid breaking pytest-flask's LiveServer fixture (uses unpicklable local functions)\nif 'pytest' not in sys.modules:\n    try:\n        if multiprocessing.get_start_method(allow_none=True) is None:\n            multiprocessing.set_start_method('spawn', force=False)\n            logger.debug(\"Set multiprocessing default to 'spawn' for thread safety (explicit contexts used everywhere)\")\n    except RuntimeError:\n        logger.debug(f\"Multiprocessing start method already set: {multiprocessing.get_start_method()}\")\n\n# Only global so we can access it in the signal handler\napp = None\ndatastore = None\n\ndef get_version():\n    return __version__\n\n# Parent wrapper or OS sends us a SIGTERM/SIGINT, do everything required for a clean shutdown\ndef sigshutdown_handler(_signo, _stack_frame):\n    name = signal.Signals(_signo).name\n    logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Fast shutdown initiated')\n\n    # Set exit flag immediately to stop all loops\n    app.config.exit.set()\n    datastore.stop_thread = True\n\n    # Log memory consumption before shutting down workers (cross-platform)\n    try:\n        import psutil\n        process = psutil.Process()\n        mem_info = process.memory_info()\n        rss_mb = mem_info.rss / 1024 / 1024\n        vms_mb = mem_info.vms / 1024 / 1024\n        logger.info(f\"Memory consumption before worker shutdown: RSS={rss_mb:,.2f} MB, VMS={vms_mb:,.2f} MB\")\n    except Exception as e:\n        logger.warning(f\"Could not retrieve memory stats: {str(e)}\")\n\n    # Shutdown workers and queues immediately\n    try:\n        from changedetectionio import worker_pool\n        worker_pool.shutdown_workers()\n    except Exception as e:\n        logger.error(f\"Error shutting down workers: {str(e)}\")\n    \n    # Close janus queues properly\n    try:\n        from changedetectionio.flask_app import update_q, notification_q\n        update_q.close()\n        notification_q.close()\n        logger.debug(\"Queues closed successfully\")\n    except Exception as e:\n        logger.critical(f\"CRITICAL: Failed to close queues: {e}\")\n    \n    # Shutdown socketio server fast\n    from changedetectionio.flask_app import socketio_server\n    if socketio_server and hasattr(socketio_server, 'shutdown'):\n        try:\n            socketio_server.shutdown()\n        except Exception as e:\n            logger.error(f\"Error shutting down Socket.IO server: {str(e)}\")\n    \n    # With immediate persistence, all data is already saved\n    logger.success('All data already persisted (immediate commits enabled).')\n\n    sys.exit()\n\ndef print_help():\n    \"\"\"Print help text for command line options\"\"\"\n    print('Usage: changedetection.py [options]')\n    print('')\n    print('Standard options:')\n    print('  -s                SSL enable')\n    print('  -h HOST           Listen host (default: 0.0.0.0)')\n    print('  -p PORT           Listen port (default: 5000)')\n    print('  -d PATH           Datastore path')\n    print('  -l LEVEL          Log level (TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL)')\n    print('  -c                Cleanup unused snapshots')\n    print('  -C                Create datastore directory if it doesn\\'t exist')\n    print('  -P true/false     Set all watches paused (true) or active (false)')\n    print('')\n    print('Add URLs on startup:')\n    print('  -u URL            Add URL to watch (can be used multiple times)')\n    print('  -u0 \\'JSON\\'        Set options for first -u URL (e.g. \\'{\"processor\":\"text_json_diff\"}\\')')\n    print('  -u1 \\'JSON\\'        Set options for second -u URL (0-indexed)')\n    print('  -u2 \\'JSON\\'        Set options for third -u URL, etc.')\n    print('                    Available options: processor, fetch_backend, headers, method, etc.')\n    print('                    See model/Watch.py for all available options')\n    print('')\n    print('Recheck on startup:')\n    print('  -r all            Queue all watches for recheck on startup')\n    print('  -r UUID,...       Queue specific watches (comma-separated UUIDs)')\n    print('  -r all N          Queue all watches, wait for completion, repeat N times')\n    print('  -r UUID,... N     Queue specific watches, wait for completion, repeat N times')\n    print('')\n    print('Batch mode:')\n    print('  -b                Run in batch mode (process queue then exit)')\n    print('                    Useful for CI/CD, cron jobs, or one-time checks')\n    print('                    NOTE: Batch mode checks if Flask is running and aborts if port is in use')\n    print('                    Use -p PORT to specify a different port if needed')\n    print('')\n\ndef main():\n    global datastore\n    global app\n\n    # Early help/version check before any initialization\n    if '--help' in sys.argv or '-help' in sys.argv:\n        print_help()\n        sys.exit(0)\n\n    if '--version' in sys.argv or '-v' in sys.argv:\n        print(f'changedetection.io {__version__}')\n        sys.exit(0)\n\n    # Import heavy modules after help/version checks to keep startup fast for those flags\n    from changedetectionio import store\n    from changedetectionio.flask_app import changedetection_app\n\n    datastore_path = None\n    # Set a default logger level\n    logger_level = 'DEBUG'\n    include_default_watches = True\n    all_paused = None  # None means don't change, True/False to set\n\n    host = os.environ.get(\"LISTEN_HOST\", \"0.0.0.0\").strip()\n    port = int(os.environ.get('PORT', 5000))\n    ssl_mode = False\n\n    # Lists for multiple URLs and their options\n    urls_to_add = []\n    url_options = {}  # Key: index (0-based), Value: dict of options\n    recheck_watches = None  # None, 'all', or list of UUIDs\n    recheck_repeat_count = 1  # Number of times to repeat recheck cycle\n    batch_mode = False  # Run once then exit when queue is empty\n\n    # On Windows, create and use a default path.\n    if os.name == 'nt':\n        datastore_path = os.path.expandvars(r'%APPDATA%\\changedetection.io')\n        os.makedirs(datastore_path, exist_ok=True)\n    else:\n        # Must be absolute so that send_from_directory doesnt try to make it relative to backend/\n        datastore_path = os.path.join(os.getcwd(), \"../datastore\")\n\n    # Pre-process arguments to extract -u, -u<N>, and -r options before getopt\n    # This allows unlimited -u0, -u1, -u2, ... options without predefining them\n    cleaned_argv = ['changedetection.py']  # Start with program name\n    i = 1\n    while i < len(sys.argv):\n        arg = sys.argv[i]\n\n        # Handle -u (add URL)\n        if arg == '-u' and i + 1 < len(sys.argv):\n            urls_to_add.append(sys.argv[i + 1])\n            i += 2\n            continue\n\n        # Handle -u<N> (set options for URL at index N)\n        if arg.startswith('-u') and len(arg) > 2 and arg[2:].isdigit():\n            idx = int(arg[2:])\n            if i + 1 < len(sys.argv):\n                try:\n                    import json\n                    url_options[idx] = json.loads(sys.argv[i + 1])\n                except json.JSONDecodeError as e:\n                    print(f'Error: Invalid JSON for {arg}: {sys.argv[i + 1]}')\n                    print(f'JSON decode error: {e}')\n                    sys.exit(2)\n                i += 2\n                continue\n\n        # Handle -r (recheck watches)\n        if arg == '-r' and i + 1 < len(sys.argv):\n            recheck_arg = sys.argv[i + 1]\n            if recheck_arg.lower() == 'all':\n                recheck_watches = 'all'\n            else:\n                # Parse comma-separated list of UUIDs\n                recheck_watches = [uuid.strip() for uuid in recheck_arg.split(',') if uuid.strip()]\n\n            # Check for optional repeat count as third argument\n            if i + 2 < len(sys.argv) and sys.argv[i + 2].isdigit():\n                recheck_repeat_count = int(sys.argv[i + 2])\n                if recheck_repeat_count < 1:\n                    print(f'Error: Repeat count must be at least 1, got {recheck_repeat_count}')\n                    sys.exit(2)\n                i += 3\n            else:\n                i += 2\n            continue\n\n        # Handle -b (batch mode - run once and exit)\n        if arg == '-b':\n            batch_mode = True\n            i += 1\n            continue\n\n        # Keep other arguments for getopt\n        cleaned_argv.append(arg)\n        i += 1\n\n    try:\n        opts, args = getopt.getopt(cleaned_argv[1:], \"6Csd:h:p:l:P:\", \"port\")\n    except getopt.GetoptError as e:\n        print_help()\n        print(f'Error: {e}')\n        sys.exit(2)\n\n    create_datastore_dir = False\n\n    # Set a logger level via shell env variable\n    # Used: Dockerfile for CICD\n    # To set logger level for pytest, see the app function in tests/conftest.py\n    if os.getenv(\"LOGGER_LEVEL\"):\n        level = os.getenv(\"LOGGER_LEVEL\")\n        logger_level = int(level) if level.isdigit() else level.upper()\n\n    for opt, arg in opts:\n        if opt == '-s':\n            ssl_mode = True\n\n        if opt == '-h':\n            host = arg\n\n        if opt == '-p':\n            port = int(arg)\n\n        if opt == '-d':\n            datastore_path = arg\n\n        # Create the datadir if it doesnt exist\n        if opt == '-C':\n            create_datastore_dir = True\n\n        if opt == '-l':\n            logger_level = int(arg) if arg.isdigit() else arg.upper()\n\n        if opt == '-P':\n            try:\n                all_paused = bool(strtobool(arg))\n            except ValueError:\n                print(f'Error: Invalid value for -P option: {arg}')\n                print('Expected: true, false, yes, no, 1, or 0')\n                sys.exit(2)\n\n    # If URLs are provided, don't include default watches\n    if urls_to_add:\n        include_default_watches = False\n\n\n    logger.success(f\"changedetection.io version {get_version()} starting.\")\n    # Launch using SocketIO run method for proper integration (if enabled)\n    ssl_cert_file = os.getenv(\"SSL_CERT_FILE\", 'cert.pem')\n    ssl_privkey_file = os.getenv(\"SSL_PRIVKEY_FILE\", 'privkey.pem')\n    if os.getenv(\"SSL_CERT_FILE\") and os.getenv(\"SSL_PRIVKEY_FILE\"):\n        ssl_mode = True\n\n    # SSL mode could have been set by -s too, therefor fallback to default values\n    if ssl_mode:\n        if not os.path.isfile(ssl_cert_file) or not os.path.isfile(ssl_privkey_file):\n            logger.critical(f\"Cannot start SSL/HTTPS mode, Please be sure that {ssl_cert_file}' and '{ssl_privkey_file}' exist in in {os.getcwd()}\")\n            os._exit(2)\n\n    # Without this, a logger will be duplicated\n    logger.remove()\n    try:\n        log_level_for_stdout = { 'TRACE', 'DEBUG', 'INFO', 'SUCCESS' }\n        logger.configure(handlers=[\n            {\"sink\": sys.stdout, \"level\": logger_level,\n             \"filter\" : lambda record: record['level'].name in log_level_for_stdout},\n            {\"sink\": sys.stderr, \"level\": logger_level,\n             \"filter\": lambda record: record['level'].name not in log_level_for_stdout},\n            ])\n    # Catch negative number or wrong log level name\n    except ValueError:\n        print(\"Available log level names: TRACE, DEBUG(default), INFO, SUCCESS,\"\n              \" WARNING, ERROR, CRITICAL\")\n        sys.exit(2)\n\n    # Disable verbose pyppeteer logging to prevent memory leaks from large CDP messages\n    # Set both parent and child loggers since pyppeteer hardcodes DEBUG level\n    logging.getLogger('pyppeteer.connection').setLevel(logging.WARNING)\n    logging.getLogger('pyppeteer.connection.Connection').setLevel(logging.WARNING)\n\n    # isnt there some @thingy to attach to each route to tell it, that this route needs a datastore\n    app_config = {\n        'datastore_path': datastore_path,\n        'batch_mode': batch_mode,\n        'recheck_watches': recheck_watches,\n        'recheck_repeat_count': recheck_repeat_count\n    }\n\n    if not os.path.isdir(app_config['datastore_path']):\n        if create_datastore_dir:\n            os.makedirs(app_config['datastore_path'], exist_ok=True)\n        else:\n            logger.critical(\n                f\"ERROR: Directory path for the datastore '{app_config['datastore_path']}'\"\n                f\" does not exist, cannot start, please make sure the\"\n                f\" directory exists or specify a directory with the -d option.\\n\"\n                f\"Or use the -C parameter to create the directory.\")\n            sys.exit(2)\n\n    try:\n        datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], version_tag=__version__, include_default_watches=include_default_watches)\n    except JSONDecodeError as e:\n        # Dont' start if the JSON DB looks corrupt\n        logger.critical(f\"ERROR: JSON DB or Proxy List JSON at '{app_config['datastore_path']}' appears to be corrupt, aborting.\")\n        logger.critical(str(e))\n        sys.exit(1)\n\n    # Testing mode: Exit cleanly after datastore initialization (for CI/CD upgrade tests)\n    if os.environ.get('TESTING_SHUTDOWN_AFTER_DATASTORE_LOAD'):\n        logger.success(f\"TESTING MODE: Datastore loaded successfully from {app_config['datastore_path']}\")\n        logger.success(f\"TESTING MODE: Schema version: {datastore.data['settings']['application'].get('schema_version', 'unknown')}\")\n        logger.success(f\"TESTING MODE: Loaded {len(datastore.data['watching'])} watches\")\n        logger.success(\"TESTING MODE: Exiting cleanly (TESTING_SHUTDOWN_AFTER_DATASTORE_LOAD is set)\")\n        sys.exit(0)\n\n    # Apply all_paused setting if specified via CLI\n    if all_paused is not None:\n        datastore.data['settings']['application']['all_paused'] = all_paused\n        logger.info(f\"Setting all watches paused: {all_paused}\")\n\n    # Inject datastore into plugins that need access to settings\n    from changedetectionio.pluggy_interface import inject_datastore_into_plugins\n    inject_datastore_into_plugins(datastore)\n\n    # Step 1: Add URLs with their options (if provided via -u flags)\n    added_watch_uuids = []\n    if urls_to_add:\n        logger.info(f\"Adding {len(urls_to_add)} URL(s) from command line\")\n        for idx, url in enumerate(urls_to_add):\n            extras = url_options.get(idx, {})\n            if extras:\n                logger.debug(f\"Adding watch {idx}: {url} with options: {extras}\")\n            else:\n                logger.debug(f\"Adding watch {idx}: {url}\")\n\n            new_uuid = datastore.add_watch(url=url, extras=extras)\n            if new_uuid:\n                added_watch_uuids.append(new_uuid)\n                logger.success(f\"Added watch: {url} (UUID: {new_uuid})\")\n            else:\n                logger.error(f\"Failed to add watch: {url}\")\n\n    app = changedetection_app(app_config, datastore)\n\n    # Step 2: Queue newly added watches (if -u was provided in batch mode)\n    # This must happen AFTER app initialization so update_q is available\n    if batch_mode and added_watch_uuids:\n        from changedetectionio.flask_app import update_q\n        from changedetectionio import queuedWatchMetaData, worker_pool\n\n        logger.info(f\"Batch mode: Queuing {len(added_watch_uuids)} newly added watches\")\n        for watch_uuid in added_watch_uuids:\n            try:\n                worker_pool.queue_item_async_safe(\n                    update_q,\n                    queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})\n                )\n                logger.debug(f\"Queued newly added watch: {watch_uuid}\")\n            except Exception as e:\n                logger.error(f\"Failed to queue watch {watch_uuid}: {e}\")\n\n    # Step 3: Queue watches for recheck (if -r was provided)\n    # This must happen AFTER app initialization so update_q is available\n    if recheck_watches is not None:\n        from changedetectionio.flask_app import update_q\n        from changedetectionio import queuedWatchMetaData, worker_pool\n\n        watches_to_queue = []\n        if recheck_watches == 'all':\n            # Queue all watches, excluding those already queued in batch mode\n            all_watches = list(datastore.data['watching'].keys())\n            if batch_mode and added_watch_uuids:\n                # Exclude newly added watches that were already queued in batch mode\n                watches_to_queue = [uuid for uuid in all_watches if uuid not in added_watch_uuids]\n                logger.info(f\"Queuing {len(watches_to_queue)} existing watches for recheck ({len(added_watch_uuids)} newly added watches already queued)\")\n            else:\n                watches_to_queue = all_watches\n                logger.info(f\"Queuing all {len(watches_to_queue)} watches for recheck\")\n        else:\n            # Queue specific UUIDs\n            watches_to_queue = recheck_watches\n            logger.info(f\"Queuing {len(watches_to_queue)} specific watches for recheck\")\n\n        queued_count = 0\n        for watch_uuid in watches_to_queue:\n            if watch_uuid in datastore.data['watching']:\n                try:\n                    worker_pool.queue_item_async_safe(\n                        update_q,\n                        queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})\n                    )\n                    queued_count += 1\n                    logger.debug(f\"Queued watch for recheck: {watch_uuid}\")\n                except Exception as e:\n                    logger.error(f\"Failed to queue watch {watch_uuid}: {e}\")\n            else:\n                logger.warning(f\"Watch UUID not found in datastore: {watch_uuid}\")\n\n        logger.success(f\"Successfully queued {queued_count} watches for recheck\")\n\n    # Step 4: Setup batch mode monitor (if -b was provided)\n    if batch_mode:\n        from changedetectionio.flask_app import update_q\n\n        # Safety check: Ensure Flask app is not already running on this port\n        # Batch mode should never run alongside the web server\n        import socket\n        test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n\n        try:\n            # Try to bind to the configured host:port (no SO_REUSEADDR - strict check)\n            test_socket.bind((host, port))\n            test_socket.close()\n            logger.debug(f\"Batch mode: Port {port} is available (Flask app not running)\")\n        except OSError as e:\n            test_socket.close()\n            # errno 98 = EADDRINUSE (Linux)\n            # errno 48 = EADDRINUSE (macOS)\n            # errno 10048 = WSAEADDRINUSE (Windows)\n            if e.errno in (48, 98, 10048) or \"Address already in use\" in str(e) or \"already in use\" in str(e).lower():\n                logger.critical(f\"ERROR: Batch mode cannot run - port {port} is already in use\")\n                logger.critical(f\"The Flask web server appears to be running on {host}:{port}\")\n                logger.critical(f\"Batch mode is designed for standalone operation (CI/CD, cron jobs, etc.)\")\n                logger.critical(f\"Please either stop the Flask web server, or use a different port with -p PORT\")\n                sys.exit(1)\n            else:\n                # Some other socket error - log but continue (might be network configuration issue)\n                logger.warning(f\"Port availability check failed with unexpected error: {e}\")\n                logger.warning(f\"Continuing with batch mode anyway - be aware of potential conflicts\")\n\n        def queue_watches_for_recheck(datastore, iteration):\n            \"\"\"Helper function to queue watches for recheck\"\"\"\n            watches_to_queue = []\n            if recheck_watches == 'all':\n                all_watches = list(datastore.data['watching'].keys())\n                if batch_mode and added_watch_uuids and iteration == 1:\n                    # Only exclude newly added watches on first iteration\n                    watches_to_queue = [uuid for uuid in all_watches if uuid not in added_watch_uuids]\n                else:\n                    watches_to_queue = all_watches\n                logger.info(f\"Batch mode (iteration {iteration}): Queuing all {len(watches_to_queue)} watches\")\n            elif recheck_watches:\n                watches_to_queue = recheck_watches\n                logger.info(f\"Batch mode (iteration {iteration}): Queuing {len(watches_to_queue)} specific watches\")\n\n            queued_count = 0\n            for watch_uuid in watches_to_queue:\n                if watch_uuid in datastore.data['watching']:\n                    try:\n                        worker_pool.queue_item_async_safe(\n                            update_q,\n                            queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})\n                        )\n                        queued_count += 1\n                    except Exception as e:\n                        logger.error(f\"Failed to queue watch {watch_uuid}: {e}\")\n                else:\n                    logger.warning(f\"Watch UUID not found in datastore: {watch_uuid}\")\n            logger.success(f\"Batch mode (iteration {iteration}): Successfully queued {queued_count} watches\")\n            return queued_count\n\n        def batch_mode_monitor():\n            \"\"\"Monitor queue and workers, shutdown or repeat when work is complete\"\"\"\n            import time\n\n            # Track iterations if repeat mode is enabled\n            current_iteration = 1\n            total_iterations = recheck_repeat_count if recheck_watches and recheck_repeat_count > 1 else 1\n\n            if total_iterations > 1:\n                logger.info(f\"Batch mode: Will repeat recheck {total_iterations} times\")\n            else:\n                logger.info(\"Batch mode: Waiting for all queued items to complete...\")\n\n            # Wait a bit for workers to start processing\n            time.sleep(3)\n\n            try:\n                while current_iteration <= total_iterations:\n                    logger.info(f\"Batch mode: Waiting for iteration {current_iteration}/{total_iterations} to complete...\")\n\n                    # Use the shared wait_for_all_checks function\n                    completed = worker_pool.wait_for_all_checks(update_q, timeout=300)\n\n                    if not completed:\n                        logger.warning(f\"Batch mode: Iteration {current_iteration} timed out after 300 seconds\")\n\n                    logger.success(f\"Batch mode: Iteration {current_iteration}/{total_iterations} completed\")\n\n                    # Check if we need to repeat\n                    if current_iteration < total_iterations:\n                        logger.info(f\"Batch mode: Starting iteration {current_iteration + 1}...\")\n                        current_iteration += 1\n\n                        # Re-queue watches for next iteration\n                        queue_watches_for_recheck(datastore, current_iteration)\n\n                        # Brief pause before continuing\n                        time.sleep(2)\n                    else:\n                        # All iterations complete\n                        logger.success(f\"Batch mode: All {total_iterations} iterations completed, initiating shutdown\")\n                        # Trigger shutdown\n                        import os, signal\n                        os.kill(os.getpid(), signal.SIGTERM)\n                        return\n\n            except Exception as e:\n                logger.error(f\"Batch mode monitor error: {e}\")\n                logger.error(f\"Initiating emergency shutdown\")\n                import os, signal\n                os.kill(os.getpid(), signal.SIGTERM)\n\n        # Start monitor in background thread\n        monitor_thread = threading.Thread(target=batch_mode_monitor, daemon=True, name=\"BatchModeMonitor\")\n        monitor_thread.start()\n        logger.info(\"Batch mode enabled: Will exit after all queued items are processed\")\n\n    # Get the SocketIO instance from the Flask app (created in flask_app.py)\n    from changedetectionio.flask_app import socketio_server\n    global socketio\n    socketio = socketio_server\n\n    signal.signal(signal.SIGTERM, sigshutdown_handler)\n    signal.signal(signal.SIGINT, sigshutdown_handler)\n    \n    # Custom signal handler for memory cleanup\n    def sigusr_clean_handler(_signo, _stack_frame):\n        from changedetectionio.gc_cleanup import memory_cleanup\n        logger.info('SIGUSR1 received: Running memory cleanup')\n        return memory_cleanup(app)\n\n    # Register the SIGUSR1 signal handler\n    # Only register the signal handler if running on Linux\n    if platform.system() == \"Linux\":\n        signal.signal(signal.SIGUSR1, sigusr_clean_handler)\n    else:\n        logger.info(\"SIGUSR1 handler only registered on Linux, skipped.\")\n\n    app.config['datastore_path'] = datastore_path\n\n\n    @app.context_processor\n    def inject_template_globals():\n        return dict(right_sticky=\"v\"+__version__,\n                    new_version_available=app.config['NEW_VERSION_AVAILABLE'],\n                    has_password=datastore.data['settings']['application']['password'] != False,\n                    socket_io_enabled=datastore.data['settings']['application'].get('ui', {}).get('socket_io_enabled', True),\n                    all_paused=datastore.data['settings']['application'].get('all_paused', False),\n                    all_muted=datastore.data['settings']['application'].get('all_muted', False)\n                    )\n\n    # Monitored websites will not receive a Referer header when a user clicks on an outgoing link.\n    @app.after_request\n    def hide_referrer(response):\n        if strtobool(os.getenv(\"HIDE_REFERER\", 'false')):\n            response.headers[\"Referrer-Policy\"] = \"same-origin\"\n\n        return response\n\n    # Proxy sub-directory support\n    # Set environment var USE_X_SETTINGS=1 on this script\n    # And then in your proxy_pass settings\n    #\n    #         proxy_set_header Host \"localhost\";\n    #         proxy_set_header X-Forwarded-Prefix /app;\n\n\n    if os.getenv('USE_X_SETTINGS'):\n        logger.info(\"USE_X_SETTINGS is ENABLED\")\n        from werkzeug.middleware.proxy_fix import ProxyFix\n        app.wsgi_app = ProxyFix(\n            app.wsgi_app,\n            x_for=1,      # X-Forwarded-For (client IP)\n            x_proto=1,    # X-Forwarded-Proto (http/https)\n            x_host=1,     # X-Forwarded-Host (original host)\n            x_port=1,     # X-Forwarded-Port (original port)\n            x_prefix=1    # X-Forwarded-Prefix (URL prefix)\n        )\n\n\n    # In batch mode, skip starting the HTTP server - just keep workers running\n    if batch_mode:\n        logger.info(\"Batch mode: Skipping HTTP server startup, workers will process queue\")\n        logger.info(\"Batch mode: Main thread will wait for shutdown signal\")\n        # Keep main thread alive until batch monitor triggers shutdown\n        try:\n            while True:\n                time.sleep(1)\n        except KeyboardInterrupt:\n            logger.info(\"Batch mode: Keyboard interrupt received\")\n            pass\n    else:\n        # Normal mode: Start HTTP server\n        # SocketIO instance is already initialized in flask_app.py\n        if socketio_server:\n            if ssl_mode:\n                logger.success(f\"SSL mode enabled, attempting to start with '{ssl_cert_file}' and '{ssl_privkey_file}' in {os.getcwd()}\")\n                socketio.run(app, host=host, port=int(port), debug=False,\n                             ssl_context=(ssl_cert_file, ssl_privkey_file), allow_unsafe_werkzeug=True)\n            else:\n                socketio.run(app, host=host, port=int(port), debug=False, allow_unsafe_werkzeug=True)\n        else:\n            # Run Flask app without Socket.IO if disabled\n            logger.info(\"Starting Flask app without Socket.IO server\")\n            if ssl_mode:\n                logger.success(f\"SSL mode enabled, attempting to start with '{ssl_cert_file}' and '{ssl_privkey_file}' in {os.getcwd()}\")\n                app.run(host=host, port=int(port), debug=False,\n                        ssl_context=(ssl_cert_file, ssl_privkey_file))\n            else:\n                app.run(host=host, port=int(port), debug=False)\n"
  },
  {
    "path": "changedetectionio/api/Import.py",
    "content": "from changedetectionio.strtobool import strtobool\nfrom flask_restful import abort, Resource\nfrom flask import request\nfrom functools import wraps\nfrom . import auth, validate_openapi_request\nfrom ..validate_url import is_safe_valid_url\nimport json\n\n# Number of URLs above which import switches to background processing\nIMPORT_SWITCH_TO_BACKGROUND_THRESHOLD = 20\n\n\ndef default_content_type(content_type='text/plain'):\n    \"\"\"Decorator to set a default Content-Type header if none is provided.\"\"\"\n    def decorator(f):\n        @wraps(f)\n        def wrapper(*args, **kwargs):\n            if not request.content_type:\n                # Set default content type in the request environment\n                request.environ['CONTENT_TYPE'] = content_type\n            return f(*args, **kwargs)\n        return wrapper\n    return decorator\n\n\ndef convert_query_param_to_type(value, schema_property):\n    \"\"\"\n    Convert a query parameter string to the appropriate type based on schema definition.\n\n    Args:\n        value: String value from query parameter\n        schema_property: Schema property definition with 'type' or 'anyOf' field\n\n    Returns:\n        Converted value in the appropriate type\n\n    Supports both OpenAPI 3.1 formats:\n    - type: [string, 'null']  (array format)\n    - anyOf: [{type: string}, {type: null}]  (anyOf format)\n    \"\"\"\n    prop_type = schema_property.get('type')\n\n    # Handle OpenAPI 3.1 type arrays: type: [string, 'null']\n    if isinstance(prop_type, list):\n        # Use the first non-null type from the array\n        for t in prop_type:\n            if t != 'null':\n                prop_type = t\n                break\n        else:\n            prop_type = None\n\n    # Handle anyOf schemas (older format)\n    elif 'anyOf' in schema_property:\n        # Use the first non-null type from anyOf\n        for option in schema_property['anyOf']:\n            if option.get('type') and option.get('type') != 'null':\n                prop_type = option.get('type')\n                break\n        else:\n            prop_type = None\n\n    # Handle array type (e.g., notification_urls)\n    if prop_type == 'array':\n        # Support both comma-separated and JSON array format\n        if value.startswith('['):\n            try:\n                return json.loads(value)\n            except json.JSONDecodeError:\n                return [v.strip() for v in value.split(',')]\n        return [v.strip() for v in value.split(',')]\n\n    # Handle object type (e.g., time_between_check, headers)\n    elif prop_type == 'object':\n        try:\n            return json.loads(value)\n        except json.JSONDecodeError:\n            raise ValueError(f\"Invalid JSON object for field: {value}\")\n\n    # Handle boolean type\n    elif prop_type == 'boolean':\n        return strtobool(value)\n\n    # Handle integer type\n    elif prop_type == 'integer':\n        return int(value)\n\n    # Handle number type (float)\n    elif prop_type == 'number':\n        return float(value)\n\n    # Default: return as string\n    return value\n\n\nclass Import(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n\n    @auth.check_token\n    @default_content_type('text/plain') #3547 #3542\n    @validate_openapi_request('importWatches')\n    def post(self):\n        \"\"\"Import a list of watched URLs with optional watch configuration.\"\"\"\n        from . import get_watch_schema_properties\n        # Special parameters that are NOT watch configuration\n        special_params = {'tag', 'tag_uuids', 'dedupe', 'proxy'}\n\n        extras = {}\n\n        # Handle special 'proxy' parameter\n        if request.args.get('proxy'):\n            plist = self.datastore.proxy_list\n            if not request.args.get('proxy') in plist:\n                proxy_list_str = ', '.join(plist) if plist else 'none configured'\n                return f\"Invalid proxy choice, currently supported proxies are '{proxy_list_str}'\", 400\n            else:\n                extras['proxy'] = request.args.get('proxy')\n\n        # Handle special 'dedupe' parameter\n        dedupe = strtobool(request.args.get('dedupe', 'true'))\n\n        # Handle special 'tag' and 'tag_uuids' parameters\n        tags = request.args.get('tag')\n        tag_uuids = request.args.get('tag_uuids')\n\n        if tag_uuids:\n            tag_uuids = tag_uuids.split(',')\n\n        # Extract ALL other query parameters as watch configuration\n        # Get schema from OpenAPI spec (replaces old schema_create_watch)\n        schema_properties = get_watch_schema_properties()\n        for param_name, param_value in request.args.items():\n            # Skip special parameters\n            if param_name in special_params:\n                continue\n\n            # Skip if not in schema (unknown parameter)\n            if param_name not in schema_properties:\n                return f\"Unknown watch configuration parameter: {param_name}\", 400\n\n            # Convert to appropriate type based on schema\n            try:\n                converted_value = convert_query_param_to_type(param_value, schema_properties[param_name])\n                extras[param_name] = converted_value\n            except (ValueError, json.JSONDecodeError) as e:\n                return f\"Invalid value for parameter '{param_name}': {str(e)}\", 400\n\n        # Validate processor if provided\n        if 'processor' in extras:\n            from changedetectionio.processors import available_processors\n            available = [p[0] for p in available_processors()]\n            if extras['processor'] not in available:\n                return f\"Invalid processor '{extras['processor']}'. Available processors: {', '.join(available)}\", 400\n\n        # Validate fetch_backend if provided\n        if 'fetch_backend' in extras:\n            from changedetectionio.content_fetchers import available_fetchers\n            available = [f[0] for f in available_fetchers()]\n            # Also allow 'system' and extra_browser_* patterns\n            is_valid = (\n                extras['fetch_backend'] == 'system' or\n                extras['fetch_backend'] in available or\n                extras['fetch_backend'].startswith('extra_browser_')\n            )\n            if not is_valid:\n                return f\"Invalid fetch_backend '{extras['fetch_backend']}'. Available: system, {', '.join(available)}\", 400\n\n        # Validate notification_urls if provided\n        if 'notification_urls' in extras:\n            from wtforms import ValidationError\n            from changedetectionio.api.Notifications import validate_notification_urls\n            try:\n                validate_notification_urls(extras['notification_urls'])\n            except ValidationError as e:\n                return f\"Invalid notification_urls: {str(e)}\", 400\n\n        urls = request.get_data().decode('utf8').splitlines()\n        # Clean and validate URLs upfront\n        urls_to_import = []\n        for url in urls:\n            url = url.strip()\n            if not len(url):\n                continue\n\n            # Validate URL\n            if not is_safe_valid_url(url):\n                return f\"Invalid or unsupported URL - {url}\", 400\n\n            # Check for duplicates if dedupe is enabled\n            if dedupe and self.datastore.url_exists(url):\n                continue\n\n            urls_to_import.append(url)\n\n        # For small imports, process synchronously for immediate feedback\n        if len(urls_to_import) < IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD:\n            added = []\n            for url in urls_to_import:\n                new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids)\n                added.append(new_uuid)\n            return added, 200\n\n        # For large imports (>= 20), process in background thread\n        else:\n            import threading\n            from loguru import logger\n\n            def import_watches_background():\n                \"\"\"Background thread to import watches - discarded after completion.\"\"\"\n                try:\n                    added_count = 0\n                    for url in urls_to_import:\n                        try:\n                            self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids)\n                            added_count += 1\n                        except Exception as e:\n                            logger.error(f\"Error importing URL {url}: {e}\")\n\n                    logger.info(f\"Background import complete: {added_count} watches created\")\n                except Exception as e:\n                    logger.error(f\"Error in background import: {e}\")\n\n            # Start background thread and return immediately\n            thread = threading.Thread(target=import_watches_background, daemon=True, name=\"ImportWatches-Background\")\n            thread.start()\n\n            return {'status': f'Importing {len(urls_to_import)} URLs in background', 'count': len(urls_to_import)}, 202"
  },
  {
    "path": "changedetectionio/api/Notifications.py",
    "content": "from flask_restful import Resource, abort\nfrom flask import request\nfrom . import auth, validate_openapi_request\n\nclass Notifications(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n\n    @auth.check_token\n    @validate_openapi_request('getNotifications')\n    def get(self):\n        \"\"\"Return Notification URL List.\"\"\"\n\n        notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])        \n\n        return {\n                'notification_urls': notification_urls,\n               }, 200\n    \n    @auth.check_token\n    @validate_openapi_request('addNotifications')\n    def post(self):\n        \"\"\"Create Notification URLs.\"\"\"\n\n        json_data = request.get_json()\n        notification_urls = json_data.get(\"notification_urls\", [])\n\n        from wtforms import ValidationError\n        try:\n            validate_notification_urls(notification_urls)\n        except ValidationError as e:\n            return str(e), 400\n\n        added_urls = []\n\n        for url in notification_urls:\n            clean_url = url.strip()\n            added_url = self.datastore.add_notification_url(clean_url)\n            if added_url:\n                added_urls.append(added_url)\n\n        if not added_urls:\n            return \"No valid notification URLs were added\", 400\n\n        return {'notification_urls': added_urls}, 201\n    \n    @auth.check_token\n    @validate_openapi_request('replaceNotifications')\n    def put(self):\n        \"\"\"Replace Notification URLs.\"\"\"\n        json_data = request.get_json()\n        notification_urls = json_data.get(\"notification_urls\", [])\n\n        from wtforms import ValidationError\n        try:\n            validate_notification_urls(notification_urls)\n        except ValidationError as e:\n            return str(e), 400\n        \n        if not isinstance(notification_urls, list):\n            return \"Invalid input format\", 400\n\n        clean_urls = [url.strip() for url in notification_urls if isinstance(url, str)]\n        self.datastore.data['settings']['application']['notification_urls'] = clean_urls\n        self.datastore.commit()\n\n        return {'notification_urls': clean_urls}, 200\n        \n    @auth.check_token\n    @validate_openapi_request('deleteNotifications')\n    def delete(self):\n        \"\"\"Delete Notification URLs.\"\"\"\n\n        json_data = request.get_json()\n        urls_to_delete = json_data.get(\"notification_urls\", [])\n        if not isinstance(urls_to_delete, list):\n            abort(400, message=\"Expected a list of notification URLs.\")\n\n        notification_urls = self.datastore.data['settings']['application'].get('notification_urls', [])\n        deleted = []\n\n        for url in urls_to_delete:\n            clean_url = url.strip()\n            if clean_url in notification_urls:\n                notification_urls.remove(clean_url)\n                deleted.append(clean_url)\n\n        if not deleted:\n            abort(400, message=\"No matching notification URLs found.\")\n\n        self.datastore.data['settings']['application']['notification_urls'] = notification_urls\n        self.datastore.commit()\n\n        return 'OK', 204\n    \ndef validate_notification_urls(notification_urls):\n    from changedetectionio.forms import ValidateAppRiseServers\n    validator = ValidateAppRiseServers()\n    class DummyForm: pass\n    dummy_form = DummyForm()\n    field = type(\"Field\", (object,), {\"data\": notification_urls, \"gettext\": lambda self, x: x})()\n    validator(dummy_form, field)"
  },
  {
    "path": "changedetectionio/api/Search.py",
    "content": "from flask_restful import Resource, abort\nfrom flask import request\nfrom . import auth, validate_openapi_request\n\nclass Search(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n\n    @auth.check_token\n    @validate_openapi_request('searchWatches')\n    def get(self):\n        \"\"\"Search for watches by URL or title text.\"\"\"\n        query = request.args.get('q', '').strip()\n        tag_limit = request.args.get('tag', '').strip()\n        from changedetectionio.strtobool import strtobool\n        partial = bool(strtobool(request.args.get('partial', '0'))) if 'partial' in request.args else False\n\n        # Require a search query\n        if not query:\n            abort(400, message=\"Search query 'q' parameter is required\")\n\n        # Use the search function from the datastore\n        matching_uuids = self.datastore.search_watches_for_url(query=query, tag_limit=tag_limit, partial=partial)\n\n        # Build the response with watch details\n        results = {}\n        for uuid in matching_uuids:\n            watch = self.datastore.data['watching'].get(uuid)\n            results[uuid] = {\n                'last_changed': watch.last_changed,\n                'last_checked': watch['last_checked'],\n                'last_error': watch['last_error'],\n                'title': watch['title'],\n                'url': watch['url'],\n                'viewed': watch.viewed\n            }\n\n        return results, 200"
  },
  {
    "path": "changedetectionio/api/Spec.py",
    "content": "import functools\nfrom flask import make_response\nfrom flask_restful import Resource\n\n\n@functools.cache\ndef _get_spec_yaml():\n    \"\"\"Build and cache the merged spec as a YAML string (only serialized once per process).\"\"\"\n    import yaml\n    from changedetectionio.api import build_merged_spec_dict\n    return yaml.dump(build_merged_spec_dict(), default_flow_style=False, allow_unicode=True)\n\n\nclass Spec(Resource):\n    def get(self):\n        \"\"\"Return the merged OpenAPI spec including all registered processor extensions.\"\"\"\n        return make_response(\n            _get_spec_yaml(),\n            200,\n            {'Content-Type': 'application/yaml'}\n        )\n"
  },
  {
    "path": "changedetectionio/api/SystemInfo.py",
    "content": "from flask_restful import Resource\nfrom . import auth, validate_openapi_request\n\n\nclass SystemInfo(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n        self.update_q = kwargs['update_q']\n\n    @auth.check_token\n    @validate_openapi_request('getSystemInfo')\n    def get(self):\n        \"\"\"Return system info.\"\"\"\n        import time\n        overdue_watches = []\n\n        # Check all watches and report which have not been checked but should have been\n\n        for uuid, watch in self.datastore.data.get('watching', {}).items():\n            # see if now - last_checked is greater than the time that should have been\n            # this is not super accurate (maybe they just edited it) but better than nothing\n            t = watch.threshold_seconds()\n            if not t:\n                # Use the system wide default\n                t = self.datastore.threshold_seconds\n\n            time_since_check = time.time() - watch.get('last_checked')\n\n            # Allow 5 minutes of grace time before we decide it's overdue\n            if time_since_check - (5 * 60) > t:\n                overdue_watches.append(uuid)\n        from changedetectionio import __version__ as main_version\n        return {\n                   'queue_size': self.update_q.qsize(),\n                   'overdue_watches': overdue_watches,\n                   'uptime': round(time.time() - self.datastore.start_time, 2),\n                   'watch_count': len(self.datastore.data.get('watching', {})),\n                   'version': main_version\n               }, 200"
  },
  {
    "path": "changedetectionio/api/Tags.py",
    "content": "from changedetectionio import queuedWatchMetaData\nfrom changedetectionio import worker_pool\nfrom flask_restful import abort, Resource\nfrom loguru import logger\n\nimport threading\nfrom flask import request\nfrom . import auth\n\nfrom . import validate_openapi_request\n\n\nclass Tag(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n        self.update_q = kwargs['update_q']\n\n    # Get information about a single tag\n    # curl http://localhost:5000/api/v1/tag/<uuid_str:uuid>\n    @auth.check_token\n    @validate_openapi_request('getTag')\n    def get(self, uuid):\n        \"\"\"Get data for a single tag/group, toggle notification muting, or recheck all.\"\"\"\n        tag = self.datastore.data['settings']['application']['tags'].get(uuid)\n        if not tag:\n            abort(404, message=f'No tag exists with the UUID of {uuid}')\n\n        if request.args.get('recheck'):\n            # Recheck all watches with this tag, including muted\n            # First collect watches to queue\n            watches_to_queue = []\n            for k in sorted(self.datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):\n                watch_uuid = k[0]\n                watch = k[1]\n                if not watch['paused'] and tag['uuid'] in watch['tags']:\n                    watches_to_queue.append(watch_uuid)\n\n            # If less than 20 watches, queue synchronously for immediate feedback\n            if len(watches_to_queue) < 20:\n                for watch_uuid in watches_to_queue:\n                    worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))\n                return {'status': f'OK, queued {len(watches_to_queue)} watches for rechecking'}, 200\n            else:\n                # 20+ watches - queue in background thread to avoid blocking API response\n                def queue_watches_background():\n                    \"\"\"Background thread to queue watches - discarded after completion.\"\"\"\n                    try:\n                        for watch_uuid in watches_to_queue:\n                            worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))\n                        logger.info(f\"Background queueing complete for tag {tag['uuid']}: {len(watches_to_queue)} watches queued\")\n                    except Exception as e:\n                        logger.error(f\"Error in background queueing for tag {tag['uuid']}: {e}\")\n\n                # Start background thread and return immediately\n                thread = threading.Thread(target=queue_watches_background, daemon=True, name=f\"QueueTag-{tag['uuid'][:8]}\")\n                thread.start()\n\n                return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202\n\n        if request.args.get('muted', '') == 'muted':\n            tag['notification_muted'] = True\n            tag.commit()\n            return \"OK\", 200\n        elif request.args.get('muted', '') == 'unmuted':\n            tag['notification_muted'] = False\n            tag.commit()\n            return \"OK\", 200\n\n        # Filter out Watch-specific runtime fields that don't apply to Tags (yet)\n        # TODO: Future enhancement - aggregate these values from all Watches that have this tag:\n        #   - check_count: sum of all watches' check_count\n        #   - last_checked: most recent last_checked from all watches\n        #   - last_changed: most recent last_changed from all watches\n        #   - consecutive_filter_failures: count of watches with failures\n        #   - etc.\n        # These come from watch_base inheritance but currently have no meaningful value for Tags\n        watch_only_fields = {\n            'browser_steps_last_error_step', 'check_count', 'consecutive_filter_failures',\n            'content-type', 'fetch_time', 'last_changed', 'last_checked', 'last_error',\n            'last_notification_error', 'last_viewed', 'notification_alert_count',\n            'page_title', 'previous_md5', 'remote_server_reply'\n        }\n\n        # Create clean tag dict without Watch-specific fields\n        clean_tag = {k: v for k, v in tag.items() if k not in watch_only_fields}\n\n        return clean_tag\n\n    @auth.check_token\n    @validate_openapi_request('deleteTag')\n    def delete(self, uuid):\n        \"\"\"Delete a tag/group and remove it from all watches.\"\"\"\n        if not self.datastore.data['settings']['application']['tags'].get(uuid):\n            abort(400, message='No tag exists with the UUID of {}'.format(uuid))\n\n        # Delete the tag, and any tag reference\n        del self.datastore.data['settings']['application']['tags'][uuid]\n\n        # Remove tag from all watches\n        for watch_uuid, watch in self.datastore.data['watching'].items():\n            if watch.get('tags') and uuid in watch['tags']:\n                watch['tags'].remove(uuid)\n                watch.commit()\n\n        return 'OK', 204\n\n    @auth.check_token\n    @validate_openapi_request('updateTag')\n    def put(self, uuid):\n        \"\"\"Update tag information.\"\"\"\n        tag = self.datastore.data['settings']['application']['tags'].get(uuid)\n        if not tag:\n            abort(404, message='No tag exists with the UUID of {}'.format(uuid))\n\n        # Make a mutable copy of request.json for modification\n        json_data = dict(request.json)\n\n        # Validate notification_urls if provided\n        if 'notification_urls' in json_data:\n            from wtforms import ValidationError\n            from changedetectionio.api.Notifications import validate_notification_urls\n            try:\n                notification_urls = json_data.get('notification_urls', [])\n                validate_notification_urls(notification_urls)\n            except ValidationError as e:\n                return str(e), 400\n\n        # Filter out readOnly fields (extracted from OpenAPI spec Tag schema)\n        # These are system-managed fields that should never be user-settable\n        from . import get_readonly_tag_fields\n        readonly_fields = get_readonly_tag_fields()\n\n        # Tag model inherits from watch_base but has no @property attributes of its own\n        # So we only need to filter readOnly fields\n        for field in readonly_fields:\n            json_data.pop(field, None)\n\n        # Validate remaining fields - reject truly unknown fields\n        # Get valid fields from Tag schema\n        from . import get_tag_schema_properties\n        valid_fields = set(get_tag_schema_properties().keys())\n\n        # Check for unknown fields\n        unknown_fields = set(json_data.keys()) - valid_fields\n        if unknown_fields:\n            return f\"Unknown field(s): {', '.join(sorted(unknown_fields))}\", 400\n\n        tag.update(json_data)\n        tag.commit()\n\n        # Clear checksums for all watches using this tag to force reprocessing\n        # Tag changes affect inherited configuration\n        cleared_count = self.datastore.clear_checksums_for_tag(uuid)\n        logger.info(f\"Tag {uuid} updated via API, cleared {cleared_count} watch checksums\")\n\n        return \"OK\", 200\n\n\n    @auth.check_token\n    @validate_openapi_request('createTag')\n    def post(self):\n        \"\"\"Create a single tag/group.\"\"\"\n\n        json_data = request.get_json()\n        title = json_data.get(\"title\",'').strip()\n\n        # Validate that only valid fields are provided\n        # Get valid fields from Tag schema\n        from . import get_tag_schema_properties\n        valid_fields = set(get_tag_schema_properties().keys())\n\n        # Check for unknown fields\n        unknown_fields = set(json_data.keys()) - valid_fields\n        if unknown_fields:\n            return f\"Unknown field(s): {', '.join(sorted(unknown_fields))}\", 400\n\n        new_uuid = self.datastore.add_tag(title=title)\n        if new_uuid:\n            # Apply any extra fields (e.g. processor_config_restock_diff) beyond just title\n            extra = {k: v for k, v in json_data.items() if k != 'title'}\n            if extra:\n                tag = self.datastore.data['settings']['application']['tags'].get(new_uuid)\n                if tag:\n                    tag.update(extra)\n                    tag.commit()\n            return {'uuid': new_uuid}, 201\n        else:\n            return \"Invalid or unsupported tag\", 400\n\nclass Tags(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n\n    @auth.check_token\n    @validate_openapi_request('listTags')\n    def get(self):\n        \"\"\"List tags/groups.\"\"\"\n        result = {}\n        for uuid, tag in self.datastore.data['settings']['application']['tags'].items():\n            result[uuid] = {\n                'date_created': tag.get('date_created', 0),\n                'notification_muted': tag.get('notification_muted', False),\n                'title': tag.get('title', ''),\n                'uuid': tag.get('uuid')\n            }\n\n        return result, 200"
  },
  {
    "path": "changedetectionio/api/Watch.py",
    "content": "import os\nimport threading\n\nfrom changedetectionio.validate_url import is_safe_valid_url\nfrom changedetectionio.favicon_utils import get_favicon_mime_type\n\nfrom . import auth\nfrom changedetectionio import queuedWatchMetaData, strtobool\nfrom changedetectionio import worker_pool\nfrom flask import request, make_response, send_from_directory\nfrom flask_restful import abort, Resource\nfrom loguru import logger\nimport copy\n\nfrom . import validate_openapi_request, get_readonly_watch_fields\nfrom ..notification import valid_notification_formats\nfrom ..notification.handler import newline_re\n\n\ndef validate_time_between_check_required(json_data):\n    \"\"\"\n    Validate that at least one time interval is specified when not using default settings.\n    Returns None if valid, or error message string if invalid.\n    Defaults to using global settings if time_between_check_use_default is not provided.\n    \"\"\"\n    # Default to using global settings if not specified\n    use_default = json_data.get('time_between_check_use_default', True)\n\n    # If using default settings, no validation needed\n    if use_default:\n        return None\n\n    # If not using defaults, check if time_between_check exists and has at least one non-zero value\n    time_check = json_data.get('time_between_check')\n    if not time_check:\n        # No time_between_check provided and not using defaults - this is an error\n        return \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\n\n    # time_between_check exists, check if it has at least one non-zero value\n    if any([\n        (time_check.get('weeks') or 0) > 0,\n        (time_check.get('days') or 0) > 0,\n        (time_check.get('hours') or 0) > 0,\n        (time_check.get('minutes') or 0) > 0,\n        (time_check.get('seconds') or 0) > 0\n    ]):\n        return None\n\n    # time_between_check exists but all values are 0 or empty - this is an error\n    return \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\n\n\nclass Watch(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n        self.update_q = kwargs['update_q']\n\n    # Get information about a single watch, excluding the history list (can be large)\n    # curl http://localhost:5000/api/v1/watch/<uuid_str:uuid>\n    # @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not \"OK\"\n    # ?recheck=true\n    @auth.check_token\n    @validate_openapi_request('getWatch')\n    def get(self, uuid):\n        \"\"\"Get information about a single watch, recheck, pause, or mute.\"\"\"\n        # Get watch reference first (for pause/mute operations)\n        watch_obj = self.datastore.data['watching'].get(uuid)\n        if not watch_obj:\n            abort(404, message='No watch exists with the UUID of {}'.format(uuid))\n\n        # Create a dict copy for JSON response (with lock for thread safety)\n        # This is much faster than deepcopy and doesn't copy the datastore reference\n        # WARNING: dict() is a SHALLOW copy - nested dicts are shared with original!\n        # Only safe because we only ADD scalar properties (line 97-101), never modify nested dicts\n        # If you need to modify nested dicts, use: from copy import deepcopy; watch = deepcopy(dict(watch_obj))\n        with self.datastore.lock:\n            watch = dict(watch_obj)\n\n        if request.args.get('recheck'):\n            worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n            return \"OK\", 200\n        if request.args.get('paused', '') == 'paused':\n            watch_obj.pause()\n            watch_obj.commit()\n            return \"OK\", 200\n        elif request.args.get('paused', '') == 'unpaused':\n            watch_obj.unpause()\n            watch_obj.commit()\n            return \"OK\", 200\n        if request.args.get('muted', '') == 'muted':\n            watch_obj.mute()\n            watch_obj.commit()\n            return \"OK\", 200\n        elif request.args.get('muted', '') == 'unmuted':\n            watch_obj.unmute()\n            watch_obj.commit()\n            return \"OK\", 200\n\n        # Return without history, get that via another API call\n        # Properties are not returned as a JSON, so add the required props manually\n        watch['history_n'] = watch_obj.history_n\n        # attr .last_changed will check for the last written text snapshot on change\n        watch['last_changed'] = watch_obj.last_changed\n        watch['viewed'] = watch_obj.viewed\n        watch['link'] = watch_obj.link,\n\n        return watch\n\n    @auth.check_token\n    @validate_openapi_request('deleteWatch')\n    def delete(self, uuid):\n        \"\"\"Delete a watch and related history.\"\"\"\n        if not self.datastore.data['watching'].get(uuid):\n            abort(400, message='No watch exists with the UUID of {}'.format(uuid))\n\n        self.datastore.delete(uuid)\n        return 'OK', 204\n\n    @auth.check_token\n    @validate_openapi_request('updateWatch')\n    def put(self, uuid):\n        \"\"\"Update watch information.\"\"\"\n        watch = self.datastore.data['watching'].get(uuid)\n        if not watch:\n            abort(404, message='No watch exists with the UUID of {}'.format(uuid))\n\n        if request.json.get('proxy'):\n            plist = self.datastore.proxy_list\n            if not plist or request.json.get('proxy') not in plist:\n                proxy_list_str = ', '.join(plist) if plist else 'none configured'\n                return f\"Invalid proxy choice, currently supported proxies are '{proxy_list_str}'\", 400\n\n        # Validate time_between_check when not using defaults\n        validation_error = validate_time_between_check_required(request.json)\n        if validation_error:\n            return validation_error, 400\n\n        # Validate notification_urls if provided\n        if 'notification_urls' in request.json:\n            from wtforms import ValidationError\n            from changedetectionio.api.Notifications import validate_notification_urls\n            try:\n                notification_urls = request.json.get('notification_urls', [])\n                validate_notification_urls(notification_urls)\n            except ValidationError as e:\n                return str(e), 400\n\n        # XSS etc protection - validate URL if it's being updated\n        if 'url' in request.json:\n            new_url = request.json.get('url')\n\n            # URL must be a non-empty string\n            if new_url is None:\n                return \"URL cannot be null\", 400\n\n            if not isinstance(new_url, str):\n                return \"URL must be a string\", 400\n\n            if not new_url.strip():\n                return \"URL cannot be empty or whitespace only\", 400\n\n            if not is_safe_valid_url(new_url.strip()):\n                return \"Invalid or unsupported URL format. URL must use http://, https://, or ftp:// protocol\", 400\n\n        # Handle processor-config-* fields separately (save to JSON, not datastore)\n        from changedetectionio import processors\n\n        # Make a mutable copy of request.json for modification\n        json_data = dict(request.json)\n\n        # Extract and remove processor config fields from json_data\n        processor_config_data = processors.extract_processor_config_from_form_data(json_data)\n\n        # Filter out readOnly fields (extracted from OpenAPI spec Watch schema)\n        # These are system-managed fields that should never be user-settable\n        readonly_fields = get_readonly_watch_fields()\n\n        # Also filter out @property attributes (computed/derived values from the model)\n        # These are not stored and should be ignored in PUT requests\n        from changedetectionio.model.Watch import model as WatchModel\n        property_fields = WatchModel.get_property_names()\n\n        # Combine both sets of fields to ignore\n        fields_to_ignore = readonly_fields | property_fields\n\n        # Remove all ignored fields from update data\n        for field in fields_to_ignore:\n            json_data.pop(field, None)\n\n        # Validate remaining fields - reject truly unknown fields\n        # Get valid fields from WatchBase schema\n        from . import get_watch_schema_properties\n        valid_fields = set(get_watch_schema_properties().keys())\n\n        # Also allow last_viewed (explicitly defined in UpdateWatch schema)\n        valid_fields.add('last_viewed')\n\n        # Check for unknown fields\n        unknown_fields = set(json_data.keys()) - valid_fields\n        if unknown_fields:\n            return f\"Unknown field(s): {', '.join(sorted(unknown_fields))}\", 400\n\n        # Update watch with regular (non-processor-config) fields\n        watch.update(json_data)\n        watch.commit()\n\n        # Save processor config to JSON file\n        processors.save_processor_config(self.datastore, uuid, processor_config_data)\n\n        return \"OK\", 200\n\n\nclass WatchHistory(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n\n    # Get a list of available history for a watch by UUID\n    # curl http://localhost:5000/api/v1/watch/<uuid_str:uuid>/history\n    @auth.check_token\n    @validate_openapi_request('getWatchHistory')\n    def get(self, uuid):\n        \"\"\"Get a list of all historical snapshots available for a watch.\"\"\"\n        watch = self.datastore.data['watching'].get(uuid)\n        if not watch:\n            abort(404, message='No watch exists with the UUID of {}'.format(uuid))\n        return watch.history, 200\n\n\nclass WatchSingleHistory(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n\n    @auth.check_token\n    @validate_openapi_request('getWatchSnapshot')\n    def get(self, uuid, timestamp):\n        \"\"\"Get single snapshot from watch.\"\"\"\n        watch = self.datastore.data['watching'].get(uuid)\n        if not watch:\n            abort(404, message=f\"No watch exists with the UUID of {uuid}\")\n\n        if not len(watch.history):\n            abort(404, message=f\"Watch found but no history exists for the UUID {uuid}\")\n\n        if timestamp == 'latest':\n            timestamp = list(watch.history.keys())[-1]\n\n        # Validate that the timestamp exists in history\n        if timestamp not in watch.history:\n            abort(404, message=f\"No history snapshot found for timestamp '{timestamp}'\")\n\n        if request.args.get('html'):\n            content = watch.get_fetched_html(timestamp)\n            if content:\n                response = make_response(content, 200)\n                response.mimetype = \"text/html\"\n            else:\n                response = make_response(\"No content found\", 404)\n                response.mimetype = \"text/plain\"\n        else:\n            content = watch.get_history_snapshot(timestamp=timestamp)\n            response = make_response(content, 200)\n            response.mimetype = \"text/plain\"\n\n        return response\n\nclass WatchHistoryDiff(Resource):\n    \"\"\"\n    Generate diff between two historical snapshots.\n\n    Note: This API endpoint currently returns text-based diffs and works best\n    with the text_json_diff processor. Future processor types (like image_diff,\n    restock_diff) may want to implement their own specialized API endpoints\n    for returning processor-specific data (e.g., price charts, image comparisons).\n\n    The web UI diff page (/diff/<uuid>) is processor-aware and delegates rendering\n    to processors/{type}/difference.py::render() for processor-specific visualizations.\n    \"\"\"\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n\n    @auth.check_token\n    @validate_openapi_request('getWatchHistoryDiff')\n    def get(self, uuid, from_timestamp, to_timestamp):\n        \"\"\"Generate diff between two historical snapshots.\"\"\"\n        from changedetectionio import diff\n        from changedetectionio.notification.handler import apply_service_tweaks\n\n        watch = self.datastore.data['watching'].get(uuid)\n        if not watch:\n            abort(404, message=f\"No watch exists with the UUID of {uuid}\")\n\n        if not len(watch.history):\n            abort(404, message=f\"Watch found but no history exists for the UUID {uuid}\")\n\n        history_keys = list(watch.history.keys())\n\n        # Handle 'latest' keyword for to_timestamp\n        if to_timestamp == 'latest':\n            to_timestamp = history_keys[-1]\n\n        # Handle 'previous' keyword for from_timestamp (second-most-recent)\n        if from_timestamp == 'previous':\n            if len(history_keys) < 2:\n                abort(404, message=f\"Not enough history entries. Need at least 2 snapshots for 'previous'\")\n            from_timestamp = history_keys[-2]\n\n        # Validate timestamps exist\n        if from_timestamp not in watch.history:\n            abort(404, message=f\"From timestamp {from_timestamp} not found in watch history\")\n        if to_timestamp not in watch.history:\n            abort(404, message=f\"To timestamp {to_timestamp} not found in watch history\")\n\n        # Get the format parameter (default to 'text')\n        output_format = request.args.get('format', 'text').lower()\n\n        # Validate format\n        if output_format not in valid_notification_formats.keys():\n            abort(400, message=f\"Invalid format. Must be one of: {', '.join(valid_notification_formats.keys())}\")\n\n        # Get the word_diff parameter (default to False - line-level mode)\n        word_diff = strtobool(request.args.get('word_diff', 'false'))\n\n        # Get the no_markup parameter (default to False)\n        no_markup = strtobool(request.args.get('no_markup', 'false'))\n\n        # Retrieve snapshot contents\n        from_version_file_contents = watch.get_history_snapshot(from_timestamp)\n        to_version_file_contents = watch.get_history_snapshot(to_timestamp)\n\n        # Get diff preferences from query parameters (matching UI preferences in DIFF_PREFERENCES_CONFIG)\n        # Support both 'type' (UI parameter) and 'word_diff' (API parameter) for backward compatibility\n        diff_type = request.args.get('type', 'diffLines')\n        if diff_type == 'diffWords':\n            word_diff = True\n\n        # Get boolean diff preferences with defaults from DIFF_PREFERENCES_CONFIG\n        changes_only = strtobool(request.args.get('changesOnly', 'false'))\n        ignore_whitespace = strtobool(request.args.get('ignoreWhitespace', 'false'))\n        include_removed = strtobool(request.args.get('removed', 'true'))\n        include_added = strtobool(request.args.get('added', 'true'))\n        include_replaced = strtobool(request.args.get('replaced', 'true'))\n\n        # Generate the diff with all preferences\n        content = diff.render_diff(\n            previous_version_file_contents=from_version_file_contents,\n            newest_version_file_contents=to_version_file_contents,\n            ignore_junk=ignore_whitespace,\n            include_equal=not changes_only,\n            include_removed=include_removed,\n            include_added=include_added,\n            include_replaced=include_replaced,\n            word_diff=word_diff,\n        )\n\n        # Skip formatting if no_markup is set\n        if no_markup:\n            mimetype = \"text/plain\"\n        else:\n            # Apply formatting based on the requested format\n            if output_format == 'htmlcolor':\n                from changedetectionio.notification.handler import apply_html_color_to_body\n                content = apply_html_color_to_body(n_body=content)\n                mimetype = \"text/html\"\n            else:\n                # Apply service tweaks for text/html formats\n                # Pass empty URL and title as they're not used for the placeholder replacement we need\n                _, content, _ = apply_service_tweaks(\n                    url='',\n                    n_body=content,\n                    n_title='',\n                    requested_output_format=output_format\n                )\n                mimetype = \"text/html\" if output_format == 'html' else \"text/plain\"\n\n            if 'html' in output_format:\n                content = newline_re.sub('<br>\\r\\n', content)\n\n        response = make_response(content, 200)\n        response.mimetype = mimetype\n        return response\n\n\nclass WatchFavicon(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n\n    @auth.check_token\n    @validate_openapi_request('getWatchFavicon')\n    def get(self, uuid):\n        \"\"\"Get favicon for a watch.\"\"\"\n        watch = self.datastore.data['watching'].get(uuid)\n        if not watch:\n            abort(404, message=f\"No watch exists with the UUID of {uuid}\")\n\n        favicon_filename = watch.get_favicon_filename()\n        if favicon_filename:\n            # Use cached MIME type detection\n            filepath = os.path.join(watch.data_dir, favicon_filename)\n            mime = get_favicon_mime_type(filepath)\n\n            response = make_response(send_from_directory(watch.data_dir, favicon_filename))\n            response.headers['Content-type'] = mime\n            response.headers['Cache-Control'] = 'max-age=300, must-revalidate'  # Cache for 5 minutes, then revalidate\n            return response\n\n        abort(404, message=f'No Favicon available for {uuid}')\n\n\nclass CreateWatch(Resource):\n    def __init__(self, **kwargs):\n        # datastore is a black box dependency\n        self.datastore = kwargs['datastore']\n        self.update_q = kwargs['update_q']\n\n    @auth.check_token\n    @validate_openapi_request('createWatch')\n    def post(self):\n        \"\"\"Create a single watch.\"\"\"\n\n        json_data = request.get_json()\n        url = json_data['url'].strip()\n\n        if not is_safe_valid_url(url):\n            return \"Invalid or unsupported URL\", 400\n\n        if json_data.get('proxy'):\n            plist = self.datastore.proxy_list\n            if not plist or json_data.get('proxy') not in plist:\n                proxy_list_str = ', '.join(plist) if plist else 'none configured'\n                return f\"Invalid proxy choice, currently supported proxies are '{proxy_list_str}'\", 400\n\n        # Validate time_between_check when not using defaults\n        validation_error = validate_time_between_check_required(json_data)\n        if validation_error:\n            return validation_error, 400\n\n        # Validate notification_urls if provided\n        if 'notification_urls' in json_data:\n            from wtforms import ValidationError\n            from changedetectionio.api.Notifications import validate_notification_urls\n            try:\n                notification_urls = json_data.get('notification_urls', [])\n                validate_notification_urls(notification_urls)\n            except ValidationError as e:\n                return str(e), 400\n\n        # Handle processor-config-* fields separately (save to JSON, not watch)\n        from changedetectionio import processors\n\n        extras = copy.deepcopy(json_data)\n\n        # Extract and remove processor config fields from extras\n        processor_config_data = processors.extract_processor_config_from_form_data(extras)\n\n        # Because we renamed 'tag' to 'tags' but don't want to change the API (can do this in v2 of the API)\n        tags = None\n        if extras.get('tag'):\n            tags = extras.get('tag')\n            del extras['tag']\n\n        del extras['url']\n\n        new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)\n\n        # Save processor config to separate JSON file\n        if new_uuid and processor_config_data:\n            processors.save_processor_config(self.datastore, new_uuid, processor_config_data)\n        if new_uuid:\n# Dont queue because the scheduler will check that it hasnt been checked before anyway\n#            worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))\n            return {'uuid': new_uuid}, 201\n        else:\n            # Check if it was a limit issue\n            page_watch_limit = os.getenv('PAGE_WATCH_LIMIT')\n            if page_watch_limit:\n                try:\n                    page_watch_limit = int(page_watch_limit)\n                    current_watch_count = len(self.datastore.data['watching'])\n                    if current_watch_count >= page_watch_limit:\n                        return f\"Watch limit reached ({current_watch_count}/{page_watch_limit} watches). Cannot add more watches.\", 429\n                except ValueError:\n                    pass\n            return \"Invalid or unsupported URL\", 400\n\n    @auth.check_token\n    @validate_openapi_request('listWatches')\n    def get(self):\n        \"\"\"List watches.\"\"\"\n        list = {}\n\n        tag_limit = request.args.get('tag', '').lower()\n        for uuid, watch in self.datastore.data['watching'].items():\n            # Watch tags by name (replace the other calls?)\n            tags = self.datastore.get_all_tags_for_watch(uuid=uuid)\n            if tag_limit and not any(v.get('title').lower() == tag_limit for k, v in tags.items()):\n                continue\n\n            list[uuid] = {\n                'last_changed': watch.last_changed,\n                'last_checked': watch['last_checked'],\n                'last_error': watch['last_error'],\n                'link': watch.link,\n                'page_title': watch['page_title'],\n                'tags': [*tags],  # Unpack dict keys to list (can't use list() since variable named 'list')\n                'title': watch['title'],\n                'url': watch['url'],\n                'viewed': watch.viewed\n            }\n\n        if request.args.get('recheck_all'):\n            # Collect all watches to queue\n            watches_to_queue = self.datastore.data['watching'].keys()\n\n            # If less than 20 watches, queue synchronously for immediate feedback\n            if len(watches_to_queue) < 20:\n                # Get already queued/running UUIDs once (efficient)\n                queued_uuids = set(self.update_q.get_queued_uuids())\n                running_uuids = set(worker_pool.get_running_uuids())\n\n                # Filter out watches that are already queued or running\n                watches_to_queue_filtered = [\n                    uuid for uuid in watches_to_queue\n                    if uuid not in queued_uuids and uuid not in running_uuids\n                ]\n\n                # Queue only the filtered watches\n                for uuid in watches_to_queue_filtered:\n                    worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n\n                # Provide feedback about skipped watches\n                skipped_count = len(watches_to_queue) - len(watches_to_queue_filtered)\n                if skipped_count > 0:\n                    return {'status': f'OK, queued {len(watches_to_queue_filtered)} watches for rechecking ({skipped_count} already queued or running)'}, 200\n                else:\n                    return {'status': f'OK, queued {len(watches_to_queue_filtered)} watches for rechecking'}, 200\n            else:\n                # 20+ watches - queue in background thread to avoid blocking API response\n                # Capture queued/running state before background thread\n                queued_uuids = set(self.update_q.get_queued_uuids())\n                running_uuids = set(worker_pool.get_running_uuids())\n\n                def queue_all_watches_background():\n                    \"\"\"Background thread to queue all watches - discarded after completion.\"\"\"\n                    try:\n                        queued_count = 0\n                        skipped_count = 0\n                        for uuid in watches_to_queue:\n                            # Check if already queued or running (state captured at start)\n                            if uuid not in queued_uuids and uuid not in running_uuids:\n                                worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n                                queued_count += 1\n                            else:\n                                skipped_count += 1\n\n                        logger.info(f\"Background queueing complete: {queued_count} watches queued, {skipped_count} skipped (already queued/running)\")\n                    except Exception as e:\n                        logger.error(f\"Error in background queueing all watches: {e}\")\n\n                # Start background thread and return immediately\n                thread = threading.Thread(target=queue_all_watches_background, daemon=True, name=\"QueueAllWatches-Background\")\n                thread.start()\n\n                return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202\n\n        return list, 200\n"
  },
  {
    "path": "changedetectionio/api/__init__.py",
    "content": "import functools\nfrom flask import request, abort\nfrom loguru import logger\n\n@functools.cache\ndef build_merged_spec_dict():\n    \"\"\"\n    Load the base OpenAPI spec and merge in any per-processor api.yaml extensions.\n\n    Each processor can provide an api.yaml file alongside its __init__.py that defines\n    additional schemas (e.g., processor_config_restock_diff). These are merged into\n    WatchBase.properties so the spec accurately reflects what the API accepts.\n\n    Plugin processors (via pluggy) are also supported - they just need an api.yaml\n    next to their processor module.\n\n    Returns the merged dict (cached - do not mutate the returned value).\n    \"\"\"\n    import os\n    import yaml\n\n    spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')\n    if not os.path.exists(spec_path):\n        spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')\n\n    with open(spec_path, 'r', encoding='utf-8') as f:\n        spec_dict = yaml.safe_load(f)\n\n    try:\n        from changedetectionio.processors import find_processors, get_parent_module\n        for module, proc_name in find_processors():\n            parent = get_parent_module(module)\n            if not parent or not hasattr(parent, '__file__'):\n                continue\n            api_yaml_path = os.path.join(os.path.dirname(parent.__file__), 'api.yaml')\n            if not os.path.exists(api_yaml_path):\n                continue\n            with open(api_yaml_path, 'r', encoding='utf-8') as f:\n                proc_spec = yaml.safe_load(f)\n            # Merge schemas\n            proc_schemas = proc_spec.get('components', {}).get('schemas', {})\n            spec_dict['components']['schemas'].update(proc_schemas)\n            # Inject processor_config_{name} into WatchBase if the schema is defined\n            schema_key = f'processor_config_{proc_name}'\n            if schema_key in proc_schemas:\n                spec_dict['components']['schemas']['WatchBase']['properties'][schema_key] = {\n                    '$ref': f'#/components/schemas/{schema_key}'\n                }\n            # Append x-code-samples from processor paths into existing path operations\n            for path, path_item in proc_spec.get('paths', {}).items():\n                if path not in spec_dict.get('paths', {}):\n                    continue\n                for method, operation in path_item.items():\n                    if method not in spec_dict['paths'][path]:\n                        continue\n                    if 'x-code-samples' in operation:\n                        existing = spec_dict['paths'][path][method].get('x-code-samples', [])\n                        spec_dict['paths'][path][method]['x-code-samples'] = existing + operation['x-code-samples']\n    except Exception as e:\n        logger.warning(f\"Failed to merge processor API specs: {e}\")\n\n    return spec_dict\n\n\n@functools.cache\ndef get_openapi_spec():\n    \"\"\"Lazy load OpenAPI spec and dependencies only when validation is needed.\"\"\"\n    from openapi_core import OpenAPI  # Lazy import - saves ~10.7 MB on startup\n    return OpenAPI.from_dict(build_merged_spec_dict())\n\n@functools.cache\ndef get_openapi_schema_dict():\n    \"\"\"\n    Get the raw OpenAPI spec dictionary for schema access.\n\n    Used by Import endpoint to validate and convert query parameters.\n    Returns the merged YAML dict (not the OpenAPI object).\n    \"\"\"\n    return build_merged_spec_dict()\n\n@functools.cache\ndef _resolve_schema_properties(schema_name):\n    \"\"\"\n    Generic helper to resolve schema properties, including allOf inheritance.\n\n    Args:\n        schema_name: Name of the schema (e.g., 'WatchBase', 'Watch', 'Tag')\n\n    Returns:\n        dict: All properties including inherited ones from $ref schemas\n    \"\"\"\n    spec_dict = get_openapi_schema_dict()\n    schema = spec_dict['components']['schemas'].get(schema_name, {})\n\n    properties = {}\n\n    # Handle allOf (schema inheritance)\n    if 'allOf' in schema:\n        for item in schema['allOf']:\n            # Resolve $ref to parent schema\n            if '$ref' in item:\n                ref_path = item['$ref'].split('/')[-1]\n                ref_schema = spec_dict['components']['schemas'].get(ref_path, {})\n                properties.update(ref_schema.get('properties', {}))\n            # Add schema-specific properties\n            if 'properties' in item:\n                properties.update(item['properties'])\n    else:\n        # Direct properties (no inheritance)\n        properties = schema.get('properties', {})\n\n    return properties\n\n\n@functools.cache\ndef get_watch_schema_properties():\n    \"\"\"\n    Extract watch schema properties from OpenAPI spec for Import endpoint.\n\n    Returns WatchBase properties (all writable Watch fields).\n    \"\"\"\n    return _resolve_schema_properties('WatchBase')\n\n# Import readonly field utilities from shared module (avoids circular dependencies with model layer)\nfrom changedetectionio.model.schema_utils import get_readonly_watch_fields, get_readonly_tag_fields\n\n@functools.cache\ndef get_tag_schema_properties():\n    \"\"\"\n    Extract Tag schema properties from OpenAPI spec.\n\n    Returns WatchBase properties + Tag-specific properties (overrides_watch).\n    \"\"\"\n    return _resolve_schema_properties('Tag')\n\ndef validate_openapi_request(operation_id):\n    \"\"\"Decorator to validate incoming requests against OpenAPI spec.\"\"\"\n    def decorator(f):\n        @functools.wraps(f)\n        def wrapper(*args, **kwargs):\n            from werkzeug.exceptions import BadRequest\n            try:\n                # Skip OpenAPI validation for GET requests since they don't have request bodies\n                if request.method.upper() != 'GET':\n                    # Lazy import - only loaded when actually validating a request\n                    from openapi_core.contrib.flask import FlaskOpenAPIRequest\n                    from openapi_core.templating.paths.exceptions import ServerNotFound, PathNotFound, PathError\n\n                    spec = get_openapi_spec()\n                    openapi_request = FlaskOpenAPIRequest(request)\n                    result = spec.unmarshal_request(openapi_request)\n                    if result.errors:\n                        error_details = []\n                        for error in result.errors:\n                            # Skip path/server validation errors for reverse proxy compatibility\n                            # Flask routing already validates that endpoints exist (returns 404 if not).\n                            # OpenAPI validation here is primarily for request body schema validation.\n                            # When behind nginx/reverse proxy, URLs may have path prefixes that don't\n                            # match the OpenAPI server definitions, causing false positives.\n                            if isinstance(error, PathError):\n                                logger.debug(f\"API Call - Skipping path/server validation (delegated to Flask): {error}\")\n                                continue\n\n                            error_str = str(error)\n                            # Extract detailed schema errors from __cause__\n                            if hasattr(error, '__cause__') and hasattr(error.__cause__, 'schema_errors'):\n                                for schema_error in error.__cause__.schema_errors:\n                                    field = '.'.join(str(p) for p in schema_error.path) if schema_error.path else 'body'\n                                    msg = schema_error.message if hasattr(schema_error, 'message') else str(schema_error)\n                                    error_details.append(f\"{field}: {msg}\")\n                            else:\n                                error_details.append(error_str)\n\n                        # Only raise if we have actual validation errors (not path/server issues)\n                        if error_details:\n                            logger.error(f\"API Call - Validation failed: {'; '.join(error_details)}\")\n                            raise BadRequest(f\"Validation failed: {'; '.join(error_details)}\")\n            except BadRequest:\n                # Re-raise BadRequest exceptions (validation failures)\n                raise\n            except Exception as e:\n                # If OpenAPI spec loading fails, log but don't break existing functionality\n                logger.critical(f\"OpenAPI validation warning for {operation_id}: {e}\")\n                abort(500)\n            return f(*args, **kwargs)\n        return wrapper\n    return decorator\n\n# Import all API resources\nfrom .Watch import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, WatchFavicon\nfrom .Tags import Tags, Tag\nfrom .Import import Import\nfrom .SystemInfo import SystemInfo\nfrom .Spec import Spec\nfrom .Notifications import Notifications\n\n"
  },
  {
    "path": "changedetectionio/api/auth.py",
    "content": "from flask import request, make_response, jsonify\nfrom functools import wraps\n\n\n# Simple API auth key comparison\n# @todo - Maybe short lived token in the future?\n\ndef check_token(f):\n    @wraps(f)\n    def decorated(*args, **kwargs):\n        datastore = args[0].datastore\n\n        config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled')\n        config_api_token = datastore.data['settings']['application'].get('api_access_token')\n\n        # config_api_token_enabled - a UI option in settings if access should obey the key or not\n        if config_api_token_enabled:\n            if request.headers.get('x-api-key') != config_api_token:\n                return make_response(\n                    jsonify(\"Invalid access - API key invalid.\"), 403\n                )\n\n        return f(*args, **kwargs)\n\n    return decorated\n"
  },
  {
    "path": "changedetectionio/auth_decorator.py",
    "content": "import os\nfrom functools import wraps\nfrom flask import current_app, redirect, request\nfrom loguru import logger\n\ndef login_optionally_required(func):\n    \"\"\"\n    If password authentication is enabled, verify the user is logged in.\n    To be used as a decorator for routes that should optionally require login.\n    This version is blueprint-friendly as it uses current_app instead of directly accessing app.\n    \"\"\"\n    @wraps(func)\n    def decorated_view(*args, **kwargs):\n        from flask import current_app\n        import flask_login\n        from flask_login import current_user\n\n        # Access datastore through the app config\n        datastore = current_app.config['DATASTORE']\n        has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv(\"SALTED_PASS\", False)\n\n        # Permitted\n        if request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):\n            return func(*args, **kwargs)\n        elif request.method in flask_login.config.EXEMPT_METHODS:\n            return func(*args, **kwargs)\n        elif current_app.config.get('LOGIN_DISABLED'):\n            return func(*args, **kwargs)\n        elif has_password_enabled and not current_user.is_authenticated:\n            return current_app.login_manager.unauthorized()\n\n        return func(*args, **kwargs)\n    return decorated_view"
  },
  {
    "path": "changedetectionio/blueprint/__init__.py",
    "content": ""
  },
  {
    "path": "changedetectionio/blueprint/backups/__init__.py",
    "content": "import datetime\nimport glob\nimport threading\n\nfrom flask import Blueprint, render_template, send_from_directory, flash, url_for, redirect, abort\nfrom flask_babel import gettext\nimport os\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.flask_app import login_optionally_required\nfrom loguru import logger\n\nBACKUP_FILENAME_FORMAT = \"changedetection-backup-{}.zip\"\n\n\ndef create_backup(datastore_path, watches: dict, tags: dict = None):\n    logger.debug(\"Creating backup...\")\n    import zipfile\n    from pathlib import Path\n\n    # create a ZipFile object\n    timestamp = datetime.datetime.now().strftime(\"%Y%m%d%H%M%S\")\n    backupname = BACKUP_FILENAME_FORMAT.format(timestamp)\n    backup_filepath = os.path.join(datastore_path, backupname)\n\n    with zipfile.ZipFile(backup_filepath.replace('.zip', '.tmp'), \"w\",\n                         compression=zipfile.ZIP_DEFLATED,\n                         compresslevel=8) as zipObj:\n\n        # Add the settings file (supports both formats)\n        # New format: changedetection.json\n        changedetection_json = os.path.join(datastore_path, \"changedetection.json\")\n        if os.path.isfile(changedetection_json):\n            zipObj.write(changedetection_json, arcname=\"changedetection.json\")\n            logger.debug(\"Added changedetection.json to backup\")\n\n        # Legacy format: url-watches.json (for backward compatibility)\n        url_watches_json = os.path.join(datastore_path, \"url-watches.json\")\n        if os.path.isfile(url_watches_json):\n            zipObj.write(url_watches_json, arcname=\"url-watches.json\")\n            logger.debug(\"Added url-watches.json to backup\")\n\n        # Add tag data directories (each tag has its own {uuid}/tag.json)\n        for uuid, tag in (tags or {}).items():\n            for f in Path(tag.data_dir).glob('*'):\n                zipObj.write(f,\n                             arcname=os.path.join(f.parts[-2], f.parts[-1]),\n                             compress_type=zipfile.ZIP_DEFLATED,\n                             compresslevel=8)\n            logger.debug(f\"Added tag '{tag.get('title')}' ({uuid}) to backup\")\n\n        # Add any data in the watch data directory.\n        for uuid, w in watches.items():\n            for f in Path(w.data_dir).glob('*'):\n                zipObj.write(f,\n                             # Use the full path to access the file, but make the file 'relative' in the Zip.\n                             arcname=os.path.join(f.parts[-2], f.parts[-1]),\n                             compress_type=zipfile.ZIP_DEFLATED,\n                             compresslevel=8)\n\n        # Create a list file with just the URLs, so it's easier to port somewhere else in the future\n        list_file = \"url-list.txt\"\n        with open(os.path.join(datastore_path, list_file), \"w\") as f:\n            for uuid in watches:\n                url = watches[uuid][\"url\"]\n                f.write(\"{}\\r\\n\".format(url))\n        list_with_tags_file = \"url-list-with-tags.txt\"\n        with open(\n                os.path.join(datastore_path, list_with_tags_file), \"w\"\n        ) as f:\n            for uuid in watches:\n                url = watches[uuid].get('url')\n                tag = watches[uuid].get('tags', {})\n                f.write(\"{} {}\\r\\n\".format(url, tag))\n\n        # Add it to the Zip\n        zipObj.write(\n            os.path.join(datastore_path, list_file),\n            arcname=list_file,\n            compress_type=zipfile.ZIP_DEFLATED,\n            compresslevel=8,\n        )\n        zipObj.write(\n            os.path.join(datastore_path, list_with_tags_file),\n            arcname=list_with_tags_file,\n            compress_type=zipfile.ZIP_DEFLATED,\n            compresslevel=8,\n        )\n\n    # Now it's done, rename it so it shows up finally and its completed being written.\n    os.rename(backup_filepath.replace('.zip', '.tmp'), backup_filepath.replace('.tmp', '.zip'))\n\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    from .restore import construct_restore_blueprint\n\n    backups_blueprint = Blueprint('backups', __name__, template_folder=\"templates\")\n    backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))\n    backup_threads = []\n\n    @login_optionally_required\n    @backups_blueprint.route(\"/request-backup\", methods=['GET'])\n    def request_backup():\n        if any(thread.is_alive() for thread in backup_threads):\n            flash(gettext(\"A backup is already running, check back in a few minutes\"), \"error\")\n            return redirect(url_for('backups.create'))\n\n        if len(find_backups()) > int(os.getenv(\"MAX_NUMBER_BACKUPS\", 100)):\n            flash(gettext(\"Maximum number of backups reached, please remove some\"), \"error\")\n            return redirect(url_for('backups.create'))\n\n        # With immediate persistence, all data is already saved\n        zip_thread = threading.Thread(\n            target=create_backup,\n            args=(datastore.datastore_path, datastore.data.get(\"watching\")),\n            kwargs={'tags': datastore.data['settings']['application'].get('tags', {})},\n            daemon=True,\n            name=\"BackupCreator\"\n        )\n        zip_thread.start()\n        backup_threads.append(zip_thread)\n        flash(gettext(\"Backup building in background, check back in a few minutes.\"))\n\n        return redirect(url_for('backups.create'))\n\n    def find_backups():\n        backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format(\"*\"))\n        backups = glob.glob(backup_filepath)\n        backup_info = []\n\n        for backup in backups:\n            size = os.path.getsize(backup) / (1024 * 1024)\n            creation_time = os.path.getctime(backup)\n            backup_info.append({\n                'filename': os.path.basename(backup),\n                'filesize': f\"{size:.2f}\",\n                'creation_time': creation_time\n            })\n\n        backup_info.sort(key=lambda x: x['creation_time'], reverse=True)\n\n        return backup_info\n\n    @login_optionally_required\n    @backups_blueprint.route(\"/download/<string:filename>\", methods=['GET'])\n    def download_backup(filename):\n        import re\n        filename = filename.strip()\n        backup_filename_regex = BACKUP_FILENAME_FORMAT.format(r\"\\d+\")\n\n        # Resolve 'latest' before any validation so checks run against the real filename.\n        if filename == 'latest':\n            backups = find_backups()\n            if not backups:\n                abort(404)\n            filename = backups[0]['filename']\n\n        if not re.match(r\"^\" + backup_filename_regex + \"$\", filename):\n            abort(400)  # Bad Request if the filename doesn't match the pattern\n\n        full_path = os.path.join(os.path.abspath(datastore.datastore_path), filename)\n        if not full_path.startswith(os.path.abspath(datastore.datastore_path) + os.sep):\n            abort(404)\n\n        logger.debug(f\"Backup download request for '{full_path}'\")\n        return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)\n\n    @login_optionally_required\n    @backups_blueprint.route(\"/\", methods=['GET'])\n    @backups_blueprint.route(\"/create\", methods=['GET'])\n    def create():\n        backups = find_backups()\n        output = render_template(\"backup_create.html\",\n                                 available_backups=backups,\n                                 backup_running=any(thread.is_alive() for thread in backup_threads)\n                                 )\n        return output\n\n    @login_optionally_required\n    @backups_blueprint.route(\"/remove-backups\", methods=['GET'])\n    def remove_backups():\n\n        backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format(\"*\"))\n        backups = glob.glob(backup_filepath)\n        for backup in backups:\n            os.unlink(backup)\n\n        flash(gettext(\"Backups were deleted.\"))\n\n        return redirect(url_for('backups.create'))\n\n    return backups_blueprint\n"
  },
  {
    "path": "changedetectionio/blueprint/backups/restore.py",
    "content": "import io\nimport json\nimport os\nimport re\nimport shutil\nimport tempfile\nimport threading\nimport zipfile\n\nfrom flask import Blueprint, render_template, flash, url_for, redirect, request\nfrom flask_babel import gettext, lazy_gettext as _l\nfrom wtforms import Form, BooleanField, SubmitField\nfrom flask_wtf.file import FileField, FileAllowed\nfrom loguru import logger\n\nfrom changedetectionio.flask_app import login_optionally_required\n\n# Maximum size of the uploaded zip file. Override via env var MAX_RESTORE_UPLOAD_MB.\n_MAX_UPLOAD_BYTES = int(os.getenv(\"MAX_RESTORE_UPLOAD_MB\", 256)) * 1024 * 1024\n# Maximum total uncompressed size of all entries (zip-bomb guard). Override via MAX_RESTORE_DECOMPRESSED_MB.\n_MAX_DECOMPRESSED_BYTES = int(os.getenv(\"MAX_RESTORE_DECOMPRESSED_MB\", 1024)) * 1024 * 1024\n# Only top-level directories whose name is a valid UUID are treated as watch/tag entries.\n_UUID_RE = re.compile(\n    r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',\n    re.IGNORECASE,\n)\n\n\nclass RestoreForm(Form):\n    zip_file = FileField(_l('Backup zip file'), validators=[\n        FileAllowed(['zip'], _l('Must be a .zip backup file!'))\n    ])\n    include_groups = BooleanField(_l('Include groups'), default=True)\n    include_groups_replace_existing = BooleanField(_l('Replace existing groups of the same UUID'), default=True)\n    include_watches = BooleanField(_l('Include watches'), default=True)\n    include_watches_replace_existing = BooleanField(_l('Replace existing watches of the same UUID'), default=True)\n    submit = SubmitField(_l('Restore backup'))\n\n\ndef import_from_zip(zip_stream, datastore, include_groups, include_groups_replace, include_watches, include_watches_replace):\n    \"\"\"\n    Extract and import watches and groups from a backup zip stream.\n\n    Mirrors the store's _load_watches / _load_tags loading pattern:\n      - UUID dirs with tag.json  → Tag.model + tag_obj.commit()\n      - UUID dirs with watch.json → rehydrate_entity + watch_obj.commit()\n\n    Returns a dict with counts: restored_groups, skipped_groups, restored_watches, skipped_watches.\n    Raises zipfile.BadZipFile if the stream is not a valid zip.\n    \"\"\"\n    from changedetectionio.model import Tag\n\n    restored_groups = 0\n    skipped_groups = 0\n    restored_watches = 0\n    skipped_watches = 0\n\n    current_tags = datastore.data['settings']['application'].get('tags', {})\n    current_watches = datastore.data['watching']\n\n    with tempfile.TemporaryDirectory() as tmpdir:\n        logger.debug(f\"Restore: extracting zip to {tmpdir}\")\n        with zipfile.ZipFile(zip_stream, 'r') as zf:\n            total_uncompressed = sum(m.file_size for m in zf.infolist())\n            if total_uncompressed > _MAX_DECOMPRESSED_BYTES:\n                raise ValueError(\n                    f\"Backup archive decompressed size ({total_uncompressed // (1024 * 1024)} MB) \"\n                    f\"exceeds the {_MAX_DECOMPRESSED_BYTES // (1024 * 1024)} MB limit\"\n                )\n            resolved_dest = os.path.realpath(tmpdir)\n            for member in zf.infolist():\n                member_dest = os.path.realpath(os.path.join(resolved_dest, member.filename))\n                if not member_dest.startswith(resolved_dest + os.sep) and member_dest != resolved_dest:\n                    raise ValueError(f\"Zip Slip path traversal detected in backup archive: {member.filename!r}\")\n                zf.extract(member, tmpdir)\n        logger.debug(\"Restore: zip extracted, scanning UUID directories\")\n\n        for entry in os.scandir(tmpdir):\n            if not entry.is_dir():\n                continue\n\n            uuid = entry.name\n            if not _UUID_RE.match(uuid):\n                logger.warning(f\"Restore: skipping non-UUID directory {uuid!r}\")\n                continue\n            tag_json_path = os.path.join(entry.path, 'tag.json')\n            watch_json_path = os.path.join(entry.path, 'watch.json')\n\n            # --- Tags (groups) ---\n            if include_groups and os.path.exists(tag_json_path):\n                if uuid in current_tags and not include_groups_replace:\n                    logger.debug(f\"Restore: skipping existing group {uuid} (replace not requested)\")\n                    skipped_groups += 1\n                    continue\n\n                try:\n                    with open(tag_json_path, 'r', encoding='utf-8') as f:\n                        tag_data = json.load(f)\n                except (json.JSONDecodeError, IOError) as e:\n                    logger.error(f\"Restore: failed to read tag.json for {uuid}: {e}\")\n                    continue\n\n                title = tag_data.get('title', uuid)\n                logger.debug(f\"Restore: importing group '{title}' ({uuid})\")\n\n                # Mirror _load_tags: set uuid and force processor\n                tag_data['uuid'] = uuid\n                tag_data['processor'] = 'restock_diff'\n\n                # Copy the UUID directory so data_dir exists for commit()\n                dst_dir = os.path.join(datastore.datastore_path, uuid)\n                if os.path.exists(dst_dir):\n                    shutil.rmtree(dst_dir)\n                shutil.copytree(entry.path, dst_dir)\n\n                tag_obj = Tag.model(\n                    datastore_path=datastore.datastore_path,\n                    __datastore=datastore.data,\n                    default=tag_data\n                )\n                current_tags[uuid] = tag_obj\n                tag_obj.commit()\n                restored_groups += 1\n                logger.success(f\"Restore: group '{title}' ({uuid}) restored\")\n\n            # --- Watches ---\n            elif include_watches and os.path.exists(watch_json_path):\n                if uuid in current_watches and not include_watches_replace:\n                    logger.debug(f\"Restore: skipping existing watch {uuid} (replace not requested)\")\n                    skipped_watches += 1\n                    continue\n\n                try:\n                    with open(watch_json_path, 'r', encoding='utf-8') as f:\n                        watch_data = json.load(f)\n                except (json.JSONDecodeError, IOError) as e:\n                    logger.error(f\"Restore: failed to read watch.json for {uuid}: {e}\")\n                    continue\n\n                url = watch_data.get('url', uuid)\n                logger.debug(f\"Restore: importing watch '{url}' ({uuid})\")\n\n                # Copy UUID directory first so data_dir and history files exist\n                dst_dir = os.path.join(datastore.datastore_path, uuid)\n                if os.path.exists(dst_dir):\n                    shutil.rmtree(dst_dir)\n                shutil.copytree(entry.path, dst_dir)\n\n                # Mirror _load_watches / rehydrate_entity\n                watch_data['uuid'] = uuid\n                watch_obj = datastore.rehydrate_entity(uuid, watch_data)\n                current_watches[uuid] = watch_obj\n                watch_obj.commit()\n                restored_watches += 1\n                logger.success(f\"Restore: watch '{url}' ({uuid}) restored\")\n\n        logger.debug(f\"Restore: scan complete - groups {restored_groups} restored / {skipped_groups} skipped, \"\n                     f\"watches {restored_watches} restored / {skipped_watches} skipped\")\n\n    # Persist changedetection.json (includes the updated tags dict)\n    logger.debug(\"Restore: committing datastore settings\")\n    datastore.commit()\n\n    return {\n        'restored_groups': restored_groups,\n        'skipped_groups': skipped_groups,\n        'restored_watches': restored_watches,\n        'skipped_watches': skipped_watches,\n    }\n\n\n\ndef construct_restore_blueprint(datastore):\n    restore_blueprint = Blueprint('restore', __name__, template_folder=\"templates\")\n    restore_threads = []\n\n    @login_optionally_required\n    @restore_blueprint.route(\"/restore\", methods=['GET'])\n    def restore():\n        form = RestoreForm()\n        return render_template(\"backup_restore.html\",\n                               form=form,\n                               restore_running=any(t.is_alive() for t in restore_threads),\n                               max_upload_mb=_MAX_UPLOAD_BYTES // (1024 * 1024),\n                               max_decompressed_mb=_MAX_DECOMPRESSED_BYTES // (1024 * 1024))\n\n    @login_optionally_required\n    @restore_blueprint.route(\"/restore/start\", methods=['POST'])\n    def backups_restore_start():\n        if any(t.is_alive() for t in restore_threads):\n            flash(gettext(\"A restore is already running, check back in a few minutes\"), \"error\")\n            return redirect(url_for('backups.restore.restore'))\n\n        zip_file = request.files.get('zip_file')\n        if not zip_file or not zip_file.filename:\n            flash(gettext(\"No file uploaded\"), \"error\")\n            return redirect(url_for('backups.restore.restore'))\n\n        if not zip_file.filename.lower().endswith('.zip'):\n            flash(gettext(\"File must be a .zip backup file\"), \"error\")\n            return redirect(url_for('backups.restore.restore'))\n\n        # Reject oversized uploads before reading the stream into memory.\n        content_length = request.content_length\n        if content_length and content_length > _MAX_UPLOAD_BYTES:\n            flash(gettext(\"Backup file is too large (max %(mb)s MB)\", mb=_MAX_UPLOAD_BYTES // (1024 * 1024)), \"error\")\n            return redirect(url_for('backups.restore.restore'))\n\n        # Read into memory now — the request stream is gone once we return.\n        # Read one byte beyond the limit so we can detect truncated-but-still-oversized streams.\n        try:\n            raw = zip_file.read(_MAX_UPLOAD_BYTES + 1)\n            if len(raw) > _MAX_UPLOAD_BYTES:\n                flash(gettext(\"Backup file is too large (max %(mb)s MB)\", mb=_MAX_UPLOAD_BYTES // (1024 * 1024)), \"error\")\n                return redirect(url_for('backups.restore.restore'))\n            zip_bytes = io.BytesIO(raw)\n            with zipfile.ZipFile(zip_bytes):  # quick validity check before spawning\n                pass\n            zip_bytes.seek(0)\n        except zipfile.BadZipFile:\n            flash(gettext(\"Invalid or corrupted zip file\"), \"error\")\n            return redirect(url_for('backups.restore.restore'))\n\n        include_groups = request.form.get('include_groups') == 'y'\n        include_groups_replace = request.form.get('include_groups_replace_existing') == 'y'\n        include_watches = request.form.get('include_watches') == 'y'\n        include_watches_replace = request.form.get('include_watches_replace_existing') == 'y'\n\n        restore_thread = threading.Thread(\n            target=import_from_zip,\n            kwargs={\n                'zip_stream': zip_bytes,\n                'datastore': datastore,\n                'include_groups': include_groups,\n                'include_groups_replace': include_groups_replace,\n                'include_watches': include_watches,\n                'include_watches_replace': include_watches_replace,\n            },\n            daemon=True,\n            name=\"BackupRestore\"\n        )\n        restore_thread.start()\n        restore_threads[:] = [t for t in restore_threads if t.is_alive()]\n        restore_threads.append(restore_thread)\n        flash(gettext(\"Restore started in background, check back in a few minutes.\"))\n        return redirect(url_for('backups.restore.restore'))\n\n    return restore_blueprint\n"
  },
  {
    "path": "changedetectionio/blueprint/backups/templates/backup_create.html",
    "content": "{% extends 'base.html' %}\n{% block content %}\n    {% from '_helpers.html' import render_simple_field, render_field %}\n\n    <div class=\"edit-form\">\n        <div class=\"tabs collapsable\">\n            <ul>\n                <li class=\"tab active\" id=\"\"><a href=\"{{ url_for('backups.create') }}\">{{ _('Create') }}</a></li>\n                <li class=\"tab\"><a href=\"{{ url_for('backups.restore.restore') }}\">{{ _('Restore') }}</a></li>\n            </ul>\n        </div>\n        <div class=\"box-wrap inner\">\n            <div id=\"general\">\n                {% if backup_running %}\n                    <p>\n                        <span class=\"spinner\"></span>&nbsp;<strong>{{ _('A backup is running!') }}</strong>\n                    </p>\n                {% endif %}\n\n                <p>\n                    {{ _('Here you can download and request a new backup, when a backup is completed you will see it listed below.') }}\n                </p>\n                <br>\n                {% if available_backups %}\n                    <ul>\n                        {% for backup in available_backups %}\n                            <li>\n                                <a href=\"{{ url_for('backups.download_backup', filename=backup[\"filename\"]) }}\">{{ backup[\"filename\"] }}</a> {{ backup[\"filesize\"] }} {{ _('Mb') }}\n                            </li>\n                        {% endfor %}\n                    </ul>\n                {% else %}\n                    <p>\n                        <strong>{{ _('No backups found.') }}</strong>\n                    </p>\n                {% endif %}\n\n                <a class=\"pure-button pure-button-primary\"\n                   href=\"{{ url_for('backups.request_backup') }}\">{{ _('Create backup') }}</a>\n                {% if available_backups %}\n                    <a class=\"pure-button button-small button-error \"\n                       href=\"{{ url_for('backups.remove_backups') }}\">{{ _('Remove backups') }}</a>\n                {% endif %}\n\n            </div>\n\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/backups/templates/backup_restore.html",
    "content": "{% extends 'base.html' %}\n{% block content %}\n    {% from '_helpers.html' import render_field, render_checkbox_field %}\n\n    <div class=\"edit-form\">\n        <div class=\"tabs collapsable\">\n            <ul>\n                <li class=\"tab\"><a href=\"{{ url_for('backups.create') }}\">{{ _('Create') }}</a></li>\n                <li class=\"tab active\"><a href=\"{{ url_for('backups.restore.restore') }}\">{{ _('Restore') }}</a></li>\n            </ul>\n        </div>\n        <div class=\"box-wrap inner\">\n            <div id=\"general\">\n                {% if restore_running %}\n                    <p>\n                        <span class=\"spinner\"></span>&nbsp;<strong>{{ _('A restore is running!') }}</strong>\n                    </p>\n                {% endif %}\n\n                <p>{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}</p>\n                <p>{{ _('Note: This does not override the main application settings, only watches and groups.') }}</p>\n                <p class=\"pure-form-message\">\n                    {{ _('Max upload size: %(upload)s MB &nbsp;·&nbsp; Max decompressed size: %(decomp)s MB',\n                         upload=max_upload_mb, decomp=max_decompressed_mb) }}\n                </p>\n\n                <form class=\"pure-form pure-form-stacked settings\"\n                      action=\"{{ url_for('backups.restore.backups_restore_start') }}\"\n                      method=\"POST\"\n                      enctype=\"multipart/form-data\">\n                    <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\">\n\n                    <div class=\"pure-control-group\">\n                        {{ render_checkbox_field(form.include_groups) }}\n                        <span class=\"pure-form-message-inline\">{{ _('Include all groups found in backup?') }}</span>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_checkbox_field(form.include_groups_replace_existing) }}\n                        <span class=\"pure-form-message-inline\">{{ _('Replace any existing groups of the same UUID?') }}</span>\n                    </div>\n\n                    <div class=\"pure-control-group\">\n                        {{ render_checkbox_field(form.include_watches) }}\n                        <span class=\"pure-form-message-inline\">{{ _('Include all watches found in backup?') }}</span>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_checkbox_field(form.include_watches_replace_existing) }}\n                        <span class=\"pure-form-message-inline\">{{ _('Replace any existing watches of the same UUID?') }}</span>\n                    </div>\n\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.zip_file) }}\n                    </div>\n\n                    <div class=\"pure-controls\">\n                        <button type=\"submit\" class=\"pure-button pure-button-primary\">{{ _('Restore backup') }}</button>\n                    </div>\n                </form>\n            </div>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/browser_steps/TODO.txt",
    "content": "- This needs an abstraction to directly handle the puppeteer connection methods\n- Then remove the playwright stuff\n- Remove hack redirect at line 65 changedetectionio/processors/__init__.py\n\nThe screenshots are base64 encoded/decoded which is very CPU intensive for large screenshots (in playwright) but not\nin the direct puppeteer connection (they are binary end to end)\n\n"
  },
  {
    "path": "changedetectionio/blueprint/browser_steps/__init__.py",
    "content": "\n# HORRIBLE HACK BUT WORKS :-) PR anyone?\n#\n# Why?\n# `browsersteps_playwright_browser_interface.chromium.connect_over_cdp()` will only run once without async()\n# - this flask app is not async()\n# - A single timeout/keepalive which applies to the session made at .connect_over_cdp()\n#\n# So it means that we must unfortunately for now just keep a single timer since .connect_over_cdp() was run\n# and know when that reaches timeout/keepalive :( when that time is up, restart the connection and tell the user\n# that their time is up, insert another coin. (reload)\n#\n#\n\nfrom changedetectionio.strtobool import strtobool\nfrom flask import Blueprint, request, make_response\nimport os\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.flask_app import login_optionally_required\nfrom loguru import logger\n\nbrowsersteps_sessions = {}\nbrowsersteps_watch_to_session = {}  # Maps watch_uuid -> browsersteps_session_id\nio_interface_context = None\nimport json\nimport hashlib\nfrom flask import Response\nimport asyncio\nimport threading\nimport time\n\n# Dedicated event loop for ALL browser steps sessions\n_browser_steps_loop = None\n_browser_steps_thread = None\n_browser_steps_loop_lock = threading.Lock()\n\ndef _start_browser_steps_loop():\n    \"\"\"Start a dedicated event loop for browser steps in its own thread\"\"\"\n    global _browser_steps_loop\n\n    # Create and set the event loop for this thread\n    loop = asyncio.new_event_loop()\n    asyncio.set_event_loop(loop)\n    _browser_steps_loop = loop\n\n    logger.debug(\"Browser steps event loop started\")\n\n    try:\n        # Run the loop forever - handles all browsersteps sessions\n        loop.run_forever()\n    except Exception as e:\n        logger.error(f\"Browser steps event loop error: {e}\")\n    finally:\n        try:\n            # Cancel all remaining tasks\n            pending = asyncio.all_tasks(loop)\n            for task in pending:\n                task.cancel()\n\n            # Wait for tasks to finish cancellation\n            if pending:\n                loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))\n        except Exception as e:\n            logger.debug(f\"Error during browser steps loop cleanup: {e}\")\n        finally:\n            loop.close()\n            logger.debug(\"Browser steps event loop closed\")\n\ndef _ensure_browser_steps_loop():\n    \"\"\"Ensure the browser steps event loop is running\"\"\"\n    global _browser_steps_loop, _browser_steps_thread\n\n    with _browser_steps_loop_lock:\n        if _browser_steps_thread is None or not _browser_steps_thread.is_alive():\n            logger.debug(\"Starting browser steps event loop thread\")\n            _browser_steps_thread = threading.Thread(\n                target=_start_browser_steps_loop,\n                daemon=True,\n                name=\"BrowserStepsEventLoop\"\n            )\n            _browser_steps_thread.start()\n\n            # Wait for the loop to be ready\n            timeout = 5.0\n            start_time = time.time()\n            while _browser_steps_loop is None:\n                if time.time() - start_time > timeout:\n                    raise RuntimeError(\"Browser steps event loop failed to start\")\n                time.sleep(0.01)\n\n            logger.debug(\"Browser steps event loop thread started and ready\")\n\ndef run_async_in_browser_loop(coro):\n    \"\"\"Run async coroutine using the dedicated browser steps event loop\"\"\"\n    _ensure_browser_steps_loop()\n\n    if _browser_steps_loop and not _browser_steps_loop.is_closed():\n        logger.debug(\"Browser steps using dedicated event loop\")\n        future = asyncio.run_coroutine_threadsafe(coro, _browser_steps_loop)\n        return future.result()\n    else:\n        raise RuntimeError(\"Browser steps event loop is not available\")\n\nasync def _close_session_resources(session_data, label=''):\n    \"\"\"Close all browser resources for a session in the correct order.\n\n    browserstepper.cleanup() closes page+context but not the browser itself.\n    For CloakBrowser, browser.close() is what stops the local Chromium process via pw.stop().\n    For the default CDP path, playwright_context.stop() shuts down the playwright instance.\n    \"\"\"\n    browserstepper = session_data.get('browserstepper')\n    if browserstepper:\n        try:\n            await browserstepper.cleanup()\n        except Exception as e:\n            logger.error(f\"Error cleaning up browserstepper{label}: {e}\")\n\n    browser = session_data.get('browser')\n    if browser:\n        try:\n            await asyncio.wait_for(browser.close(), timeout=5.0)\n        except Exception as e:\n            logger.warning(f\"Error closing browser{label}: {e}\")\n\n    playwright_context = session_data.get('playwright_context')\n    if playwright_context:\n        try:\n            await playwright_context.stop()\n        except Exception as e:\n            logger.warning(f\"Error stopping playwright context{label}: {e}\")\n\n\ndef cleanup_expired_sessions():\n    \"\"\"Remove expired browsersteps sessions and cleanup their resources\"\"\"\n    global browsersteps_sessions, browsersteps_watch_to_session\n\n    expired_session_ids = []\n\n    # Find expired sessions\n    for session_id, session_data in browsersteps_sessions.items():\n        browserstepper = session_data.get('browserstepper')\n        if browserstepper and browserstepper.has_expired:\n            expired_session_ids.append(session_id)\n\n    # Cleanup expired sessions\n    for session_id in expired_session_ids:\n        logger.debug(f\"Cleaning up expired browsersteps session {session_id}\")\n        session_data = browsersteps_sessions[session_id]\n\n        try:\n            run_async_in_browser_loop(_close_session_resources(session_data, label=f\" for session {session_id}\"))\n        except Exception as e:\n            logger.error(f\"Error cleaning up session {session_id}: {e}\")\n\n        # Remove from sessions dict\n        del browsersteps_sessions[session_id]\n\n        # Remove from watch mapping\n        for watch_uuid, mapped_session_id in list(browsersteps_watch_to_session.items()):\n            if mapped_session_id == session_id:\n                del browsersteps_watch_to_session[watch_uuid]\n                break\n\n    if expired_session_ids:\n        logger.info(f\"Cleaned up {len(expired_session_ids)} expired browsersteps session(s)\")\n\ndef cleanup_session_for_watch(watch_uuid):\n    \"\"\"Cleanup a specific browsersteps session for a watch UUID\"\"\"\n    global browsersteps_sessions, browsersteps_watch_to_session\n\n    session_id = browsersteps_watch_to_session.get(watch_uuid)\n    if not session_id:\n        logger.debug(f\"No browsersteps session found for watch {watch_uuid}\")\n        return\n\n    logger.debug(f\"Cleaning up browsersteps session {session_id} for watch {watch_uuid}\")\n\n    session_data = browsersteps_sessions.get(session_id)\n    if session_data:\n        try:\n            run_async_in_browser_loop(_close_session_resources(session_data, label=f\" for watch {watch_uuid}\"))\n        except Exception as e:\n            logger.error(f\"Error cleaning up session {session_id} for watch {watch_uuid}: {e}\")\n\n        # Remove from sessions dict\n        del browsersteps_sessions[session_id]\n\n    # Remove from watch mapping\n    del browsersteps_watch_to_session[watch_uuid]\n\n    logger.debug(f\"Cleaned up session for watch {watch_uuid}\")\n\n    # Opportunistically cleanup any other expired sessions\n    cleanup_expired_sessions()\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder=\"templates\")\n\n    async def start_browsersteps_session(watch_uuid):\n        from changedetectionio.browser_steps import browser_steps\n        import time\n        from playwright.async_api import async_playwright\n\n        keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60\n        keepalive_ms = ((keepalive_seconds + 3) * 1000)\n\n        browsersteps_start_session = {'start_time': time.time()}\n\n        # Build proxy dict first — needed by both the CDP path and fetcher-specific launchers\n        proxy_id = datastore.get_preferred_proxy_for_watch(uuid=watch_uuid)\n        proxy = None\n        if proxy_id:\n            proxy_url = datastore.proxy_list.get(proxy_id, {}).get('url')\n            if proxy_url:\n                from urllib.parse import urlparse\n                parsed = urlparse(proxy_url)\n                proxy = {'server': proxy_url}\n                if parsed.username:\n                    proxy['username'] = parsed.username\n                if parsed.password:\n                    proxy['password'] = parsed.password\n                logger.debug(f\"Browser Steps: UUID {watch_uuid} selected proxy {proxy_url}\")\n\n        # Resolve the fetcher class for this watch so we can ask it to launch its own browser\n        # if it supports that (e.g. CloakBrowser, which runs locally rather than via CDP)\n        watch = datastore.data['watching'][watch_uuid]\n        from changedetectionio import content_fetchers\n        fetcher_name = watch.get_fetch_backend or 'system'\n        if fetcher_name == 'system':\n            fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests')\n        fetcher_class = getattr(content_fetchers, fetcher_name, None)\n\n        browser = None\n        playwright_context = None\n\n        # If the fetcher has its own browser launch for the live steps UI, use it.\n        # get_browsersteps_browser(proxy, keepalive_ms) returns (browser, playwright_context_or_None)\n        # or None to fall back to the default CDP path.\n        if fetcher_class and hasattr(fetcher_class, 'get_browsersteps_browser'):\n            result = await fetcher_class.get_browsersteps_browser(proxy=proxy, keepalive_ms=keepalive_ms)\n            if result is not None:\n                browser, playwright_context = result\n                logger.debug(f\"Browser Steps: using fetcher-specific browser for '{fetcher_name}'\")\n\n        # Default: connect to the remote Playwright/sockpuppetbrowser via CDP\n        if browser is None:\n            playwright_instance = async_playwright()\n            playwright_context = await playwright_instance.start()\n            base_url = os.getenv('PLAYWRIGHT_DRIVER_URL', '').strip('\"')\n            a = \"?\" if '?' not in base_url else '&'\n            base_url += a + f\"timeout={keepalive_ms}\"\n            browser = await playwright_context.chromium.connect_over_cdp(base_url, timeout=keepalive_ms)\n            logger.debug(f\"Browser Steps: using CDP connection to {base_url}\")\n\n        browsersteps_start_session['browser'] = browser\n        browsersteps_start_session['playwright_context'] = playwright_context\n\n        browserstepper = browser_steps.browsersteps_live_ui(\n            playwright_browser=browser,\n            proxy=proxy,\n            start_url=watch.link,\n            headers=watch.get('headers')\n        )\n        await browserstepper.connect(proxy=proxy)\n        browsersteps_start_session['browserstepper'] = browserstepper\n\n        return browsersteps_start_session\n\n\n    @login_optionally_required\n    @browser_steps_blueprint.route(\"/browsersteps_start_session\", methods=['GET'])\n    def browsersteps_start_session():\n        # A new session was requested, return sessionID\n        import uuid\n        browsersteps_session_id = str(uuid.uuid4())\n        watch_uuid = request.args.get('uuid')\n\n        if not watch_uuid:\n            return make_response('No Watch UUID specified', 500)\n\n        # Cleanup any existing session for this watch\n        cleanup_session_for_watch(watch_uuid)\n\n        logger.debug(\"Starting connection with playwright\")\n        logger.debug(\"browser_steps.py connecting\")\n\n        try:\n            # Run the async function in the dedicated browser steps event loop\n            browsersteps_sessions[browsersteps_session_id] = run_async_in_browser_loop(\n                start_browsersteps_session(watch_uuid)\n            )\n\n            # Store the mapping of watch_uuid -> browsersteps_session_id\n            browsersteps_watch_to_session[watch_uuid] = browsersteps_session_id\n\n        except Exception as e:\n            if 'ECONNREFUSED' in str(e):\n                return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401)\n            else:\n                # Other errors, bad URL syntax, bad reply etc\n                return make_response(str(e), 401)\n\n        logger.debug(\"Starting connection with playwright - done\")\n        return {'browsersteps_session_id': browsersteps_session_id}\n\n    @login_optionally_required\n    @browser_steps_blueprint.route(\"/browsersteps_image\", methods=['GET'])\n    def browser_steps_fetch_screenshot_image():\n        from flask import (\n            make_response,\n            request,\n            send_from_directory,\n        )\n        uuid = request.args.get('uuid')\n        step_n = int(request.args.get('step_n'))\n\n        watch = datastore.data['watching'].get(uuid)\n        filename = f\"step_before-{step_n}.jpeg\" if request.args.get('type', '') == 'before' else f\"step_{step_n}.jpeg\"\n\n        if step_n and watch and os.path.isfile(os.path.join(watch.data_dir, filename)):\n            response = make_response(send_from_directory(directory=watch.data_dir, path=filename))\n            response.headers['Content-type'] = 'image/jpeg'\n            response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'\n            response.headers['Pragma'] = 'no-cache'\n            response.headers['Expires'] = 0\n            return response\n\n        else:\n            return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)\n\n    # A request for an action was received\n    @login_optionally_required\n    @browser_steps_blueprint.route(\"/browsersteps_update\", methods=['POST'])\n    def browsersteps_ui_update():\n        import base64\n\n        remaining = 0\n        uuid = request.args.get('uuid')\n        goto_website_url_first_step = request.args.get('goto_website_url_first_step')\n\n        browsersteps_session_id = request.args.get('browsersteps_session_id')\n\n        if not browsersteps_session_id:\n            return make_response('No browsersteps_session_id specified', 500)\n\n        if not browsersteps_sessions.get(browsersteps_session_id):\n            return make_response('No session exists under that ID', 500)\n\n        is_last_step = False\n\n        # @todo - should always be an existing session\n        if goto_website_url_first_step:\n            logger.debug(\"Going to site (requested automatically before stepping)..\")\n            step_operation = \"Goto site\"\n            step_selector = None\n            step_optional_value = None\n        else:\n            step_operation = request.form.get('operation')\n            step_selector = request.form.get('selector')\n            step_optional_value = request.form.get('optional_value')\n            is_last_step = strtobool(request.form.get('is_last_step'))\n\n        try:\n            # Run the async call_action method in the dedicated browser steps event loop\n            run_async_in_browser_loop(\n                browsersteps_sessions[browsersteps_session_id]['browserstepper'].call_action(\n                    action_name=step_operation,\n                    selector=step_selector,\n                    optional_value=step_optional_value\n                )\n            )\n\n        except Exception as e:\n            logger.error(f\"Exception when calling step operation {step_operation} {str(e)}\")\n            # Try to find something of value to give back to the user\n            return make_response(str(e).splitlines()[0], 401)\n\n        # Screenshots and other info only needed on requesting a step (POST)\n        try:\n            # Run the async get_current_state method in the dedicated browser steps event loop\n            (screenshot, xpath_data) = run_async_in_browser_loop(\n                browsersteps_sessions[browsersteps_session_id]['browserstepper'].get_current_state()\n            )\n\n            if is_last_step:\n                watch = datastore.data['watching'].get(uuid)\n                u = browsersteps_sessions[browsersteps_session_id]['browserstepper'].page.url\n                if watch and u:\n                    watch.save_screenshot(screenshot=screenshot)\n                    watch.save_xpath_data(data=xpath_data)\n\n        except Exception as e:\n            return make_response(f\"Error fetching screenshot and element data - {str(e)}\", 401)\n\n        # SEND THIS BACK TO THE BROWSER\n        output = {\n            \"screenshot\": f\"data:image/jpeg;base64,{base64.b64encode(screenshot).decode('ascii')}\",\n            \"xpath_data\": xpath_data,\n            \"session_age_start\": browsersteps_sessions[browsersteps_session_id]['browserstepper'].age_start,\n            \"browser_time_remaining\": round(remaining)\n        }\n        json_data = json.dumps(output)\n\n        # Generate an ETag (hash of the response body)\n        etag_hash = hashlib.md5(json_data.encode('utf-8')).hexdigest()\n\n        # Create the response with ETag\n        response = Response(json_data, mimetype=\"application/json; charset=UTF-8\")\n        response.set_etag(etag_hash)\n\n        return response\n\n    return browser_steps_blueprint\n\n\n"
  },
  {
    "path": "changedetectionio/blueprint/check_proxies/__init__.py",
    "content": "import importlib\nfrom concurrent.futures import ThreadPoolExecutor\n\nfrom changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse\nfrom changedetectionio.store import ChangeDetectionStore\n\nfrom functools import wraps\n\nfrom flask import Blueprint\nfrom flask_login import login_required\n\nSTATUS_CHECKING = 0\nSTATUS_FAILED = 1\nSTATUS_OK = 2\nTHREADPOOL_MAX_WORKERS = 3\n_DEFAULT_POOL = ThreadPoolExecutor(max_workers=THREADPOOL_MAX_WORKERS)\n\n\n# Maybe use fetch-time if its >5 to show some expected load time?\ndef threadpool(f, executor=None):\n    @wraps(f)\n    def wrap(*args, **kwargs):\n        return (executor or _DEFAULT_POOL).submit(f, *args, **kwargs)\n\n    return wrap\n\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    check_proxies_blueprint = Blueprint('check_proxies', __name__)\n    checks_in_progress = {}\n\n    @threadpool\n    def long_task(uuid, preferred_proxy):\n        import time\n        from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions\n        from changedetectionio.jinja2_custom import render as jinja_render\n\n        status = {'status': '', 'length': 0, 'text': ''}\n\n        contents = ''\n        now = time.time()\n        try:\n            processor_module = importlib.import_module(\"changedetectionio.processors.text_json_diff.processor\")\n            update_handler = processor_module.perform_site_check(datastore=datastore,\n                                                                 watch_uuid=uuid\n                                                                 )\n\n            update_handler.call_browser(preferred_proxy_id=preferred_proxy)\n        # title, size is len contents not len xfer\n        except content_fetcher_exceptions.Non200ErrorCodeReceived as e:\n            if e.status_code == 404:\n                status.update({'status': 'OK', 'length': len(contents), 'text': f\"OK but 404 (page not found)\"})\n            elif e.status_code == 403 or e.status_code == 401:\n                status.update({'status': 'ERROR', 'length': len(contents), 'text': f\"{e.status_code} - Access denied\"})\n            else:\n                status.update({'status': 'ERROR', 'length': len(contents), 'text': f\"Status code: {e.status_code}\"})\n        except FilterNotFoundInResponse:\n            status.update({'status': 'OK', 'length': len(contents), 'text': f\"OK but CSS/xPath filter not found (page changed layout?)\"})\n        except content_fetcher_exceptions.EmptyReply as e:\n            if e.status_code == 403 or e.status_code == 401:\n                status.update({'status': 'ERROR OTHER', 'length': len(contents), 'text': f\"Got empty reply with code {e.status_code} - Access denied\"})\n            else:\n                status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': f\"Empty reply with code {e.status_code}, needs chrome?\"})\n        except content_fetcher_exceptions.ReplyWithContentButNoText as e:\n            txt = f\"Got reply but with no content - Status code {e.status_code} - It's possible that the filters were found, but contained no usable text (or contained only an image).\"\n            status.update({'status': 'ERROR', 'text': txt})\n        except Exception as e:\n            status.update({'status': 'ERROR OTHER', 'length': len(contents) if contents else 0, 'text': 'Error: '+type(e).__name__+str(e)})\n        else:\n            status.update({'status': 'OK', 'length': len(contents), 'text': ''})\n\n        if status.get('text'):\n            # parse 'text' as text for safety\n            v = {'text': status['text']}\n            status['text'] = jinja_render(template_str='{{text|e}}', **v)\n\n        status['time'] = \"{:.2f}s\".format(time.time() - now)\n\n        return status\n\n    def _recalc_check_status(uuid):\n\n        results = {}\n        for k, v in checks_in_progress.get(uuid, {}).items():\n            try:\n                r_1 = v.result(timeout=0.05)\n            except Exception as e:\n                # If timeout error?\n                results[k] = {'status': 'RUNNING'}\n\n            else:\n                results[k] = r_1\n\n        return results\n\n    @login_required\n    @check_proxies_blueprint.route(\"/<uuid_str:uuid>/status\", methods=['GET'])\n    def get_recheck_status(uuid):\n        results = _recalc_check_status(uuid=uuid)\n        return results\n\n    @login_required\n    @check_proxies_blueprint.route(\"/<uuid_str:uuid>/start\", methods=['GET'])\n    def start_check(uuid):\n\n        if not datastore.proxy_list:\n            return\n\n        if checks_in_progress.get(uuid):\n            state = _recalc_check_status(uuid=uuid)\n            for proxy_key, v in state.items():\n                if v.get('status') == 'RUNNING':\n                    return state\n        else:\n            checks_in_progress[uuid] = {}\n\n        for k, v in datastore.proxy_list.items():\n            if not checks_in_progress[uuid].get(k):\n                checks_in_progress[uuid][k] = long_task(uuid=uuid, preferred_proxy=k)\n\n        results = _recalc_check_status(uuid=uuid)\n        return results\n\n    return check_proxies_blueprint\n"
  },
  {
    "path": "changedetectionio/blueprint/imports/__init__.py",
    "content": "from flask import Blueprint, request, redirect, url_for, flash, render_template\nfrom loguru import logger\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.auth_decorator import login_optionally_required\n\ndef construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):\n    import_blueprint = Blueprint('imports', __name__, template_folder=\"templates\")\n    \n    @import_blueprint.route(\"/import\", methods=['GET', 'POST'])\n    @login_optionally_required\n    def import_page():\n        remaining_urls = []\n        from changedetectionio import forms\n#\n        if request.method == 'POST':\n#            from changedetectionio import worker_pool\n\n            from changedetectionio.blueprint.imports.importer import (\n                import_url_list,\n                import_distill_io_json,\n                import_xlsx_wachete,\n                import_xlsx_custom\n            )\n\n            # URL List import\n            if request.values.get('urls') and len(request.values.get('urls').strip()):\n                # Import and push into the queue for immediate update check\n                from changedetectionio import processors\n                importer_handler = import_url_list()\n                importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', processors.get_default_processor()))\n                logger.debug(f\"Imported {len(importer_handler.new_uuids)} new UUIDs\")\n                # Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue\n#                for uuid in importer_handler.new_uuids:\n#                    worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n\n                if len(importer_handler.remaining_data) == 0:\n                    return redirect(url_for('watchlist.index'))\n                else:\n                    remaining_urls = importer_handler.remaining_data\n\n            # Distill.io import\n            if request.values.get('distill-io') and len(request.values.get('distill-io').strip()):\n                # Import and push into the queue for immediate update check\n                d_importer = import_distill_io_json()\n                d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore)\n                # Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue\n#                for uuid in importer_handler.new_uuids:\n#                    worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n\n\n            # XLSX importer\n            if request.files and request.files.get('xlsx_file'):\n                file = request.files['xlsx_file']\n\n                if request.values.get('file_mapping') == 'wachete':\n                    w_importer = import_xlsx_wachete()\n                    w_importer.run(data=file, flash=flash, datastore=datastore)\n                else:\n                    w_importer = import_xlsx_custom()\n                    # Building mapping of col # to col # type\n                    map = {}\n                    for i in range(10):\n                        c = request.values.get(f\"custom_xlsx[col_{i}]\")\n                        v = request.values.get(f\"custom_xlsx[col_type_{i}]\")\n                        if c and v:\n                            map[int(c)] = v\n\n                    w_importer.import_profile = map\n                    w_importer.run(data=file, flash=flash, datastore=datastore)\n\n                # Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue\n#                for uuid in importer_handler.new_uuids:\n#                    worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n\n\n        # Could be some remaining, or we could be on GET\n        form = forms.importForm(formdata=request.form if request.method == 'POST' else None)\n        output = render_template(\"import.html\",\n                                form=form,\n                                import_url_list_remaining=\"\\n\".join(remaining_urls),\n                                original_distill_json=''\n                                )\n        return output\n\n    return import_blueprint"
  },
  {
    "path": "changedetectionio/blueprint/imports/importer.py",
    "content": "from abc import abstractmethod\nimport time\nfrom wtforms import ValidationError\nfrom loguru import logger\nfrom flask_babel import gettext\n\nfrom changedetectionio.forms import validate_url\n\n\nclass Importer():\n    remaining_data = []\n    new_uuids = []\n    good = 0\n\n    def __init__(self):\n        self.new_uuids = []\n        self.good = 0\n        self.remaining_data = []\n        self.import_profile = None\n\n    @abstractmethod\n    def run(self,\n            data,\n            flash,\n            datastore):\n        pass\n\n\nclass import_url_list(Importer):\n    \"\"\"\n    Imports a list, can be in <code>https://example.com tag1, tag2, last tag</code> format\n    \"\"\"\n    def run(self,\n            data,\n            flash,\n            datastore,\n            processor=None\n            ):\n\n        urls = data.split(\"\\n\")\n        good = 0\n        now = time.time()\n\n        if (len(urls) > 5000):\n            flash(gettext(\"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"))\n\n        for url in urls:\n            url = url.strip()\n            if not len(url):\n                continue\n\n            tags = \"\"\n\n            # 'tags' should be a csv list after the URL\n            if ' ' in url:\n                url, tags = url.split(\" \", 1)\n\n            # Flask wtform validators wont work with basic auth, use validators package\n            # Up to 5000 per batch so we dont flood the server\n            # @todo validators.url will fail when you add your own IP etc\n            if len(url) and 'http' in url.lower() and good < 5000:\n                extras = None\n                if processor:\n                    extras = {'processor': processor}\n                new_uuid = datastore.add_watch(url=url.strip(), tag=tags, save_immediately=False, extras=extras)\n\n                if new_uuid:\n                    # Straight into the queue.\n                    self.new_uuids.append(new_uuid)\n                    good += 1\n                    continue\n\n            # Worked past the 'continue' above, append it to the bad list\n            if self.remaining_data is None:\n                self.remaining_data = []\n            self.remaining_data.append(url)\n\n        flash(gettext(\"{} Imported from list in {:.2f}s, {} Skipped.\").format(good, time.time() - now, len(self.remaining_data)))\n\n\nclass import_distill_io_json(Importer):\n    def run(self,\n            data,\n            flash,\n            datastore,\n            ):\n\n        import json\n        good = 0\n        now = time.time()\n        self.new_uuids=[]\n\n        # @todo Use JSONSchema like in the API to validate here.\n        \n        try:\n            data = json.loads(data.strip())\n        except json.decoder.JSONDecodeError:\n            flash(gettext(\"Unable to read JSON file, was it broken?\"), 'error')\n            return\n\n        if not data.get('data'):\n            flash(gettext(\"JSON structure looks invalid, was it broken?\"), 'error')\n            return\n\n        for d in data.get('data'):\n            d_config = json.loads(d['config'])\n            extras = {'title': d.get('name', None)}\n\n            if len(d['uri']) and good < 5000:\n                try:\n                    # @todo we only support CSS ones at the moment\n                    if d_config['selections'][0]['frames'][0]['excludes'][0]['type'] == 'css':\n                        extras['subtractive_selectors'] = d_config['selections'][0]['frames'][0]['excludes'][0]['expr']\n                except KeyError:\n                    pass\n                except IndexError:\n                    pass\n                extras['include_filters'] = []\n                try:\n                    if d_config['selections'][0]['frames'][0]['includes'][0]['type'] == 'xpath':\n                        extras['include_filters'].append('xpath:' + d_config['selections'][0]['frames'][0]['includes'][0]['expr'])\n                    else:\n                        extras['include_filters'].append(d_config['selections'][0]['frames'][0]['includes'][0]['expr'])\n                except KeyError:\n                    pass\n                except IndexError:\n                    pass\n\n                new_uuid = datastore.add_watch(url=d['uri'].strip(),\n                                               tag=\",\".join(d.get('tags', [])),\n                                               extras=extras,\n                                               save_immediately=False)\n\n                if new_uuid:\n                    # Straight into the queue.\n                    self.new_uuids.append(new_uuid)\n                    good += 1\n\n        flash(gettext(\"{} Imported from Distill.io in {:.2f}s, {} Skipped.\").format(len(self.new_uuids), time.time() - now, len(self.remaining_data)))\n\n\nclass import_xlsx_wachete(Importer):\n\n    def run(self,\n            data,\n            flash,\n            datastore,\n            ):\n\n        good = 0\n        now = time.time()\n        self.new_uuids = []\n\n        from openpyxl import load_workbook\n\n        try:\n            wb = load_workbook(data)\n        except Exception as e:\n            # @todo correct except\n            flash(gettext(\"Unable to read export XLSX file, something wrong with the file?\"), 'error')\n            return\n\n        row_id = 2\n        for row in wb.active.iter_rows(min_row=row_id):\n            try:\n                extras = {}\n                data = {}\n                for cell in row:\n                    if not cell.value:\n                        continue\n                    column_title = wb.active.cell(row=1, column=cell.column).value.strip().lower()\n                    data[column_title] = cell.value\n\n                # Forced switch to webdriver/playwright/etc\n                dynamic_wachet = str(data.get('dynamic wachet', '')).strip().lower()  # Convert bool to str to cover all cases\n                # libreoffice and others can have it as =FALSE() =TRUE(), or bool(true)\n                if 'true' in dynamic_wachet or dynamic_wachet == '1':\n                    extras['fetch_backend'] = 'html_webdriver'\n                elif 'false' in dynamic_wachet or dynamic_wachet == '0':\n                    extras['fetch_backend'] = 'html_requests'\n\n                if data.get('xpath'):\n                    # @todo split by || ?\n                    extras['include_filters'] = [data.get('xpath')]\n                if data.get('name'):\n                    extras['title'] = data.get('name').strip()\n                if data.get('interval (min)'):\n                    minutes = int(data.get('interval (min)'))\n                    hours, minutes = divmod(minutes, 60)\n                    days, hours = divmod(hours, 24)\n                    weeks, days = divmod(days, 7)\n                    extras['time_between_check'] = {'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': 0}\n\n                # At minimum a URL is required.\n                if data.get('url'):\n                    try:\n                        validate_url(data.get('url'))\n                    except ValidationError as e:\n                        logger.error(f\">> Import URL error {data.get('url')} {str(e)}\")\n                        flash(gettext(\"Error processing row number {}, URL value was incorrect, row was skipped.\").format(row_id), 'error')\n                        # Don't bother processing anything else on this row\n                        continue\n\n                    new_uuid = datastore.add_watch(url=data['url'].strip(),\n                                                   extras=extras,\n                                                   tag=data.get('folder'),\n                                                   save_immediately=False)\n                    if new_uuid:\n                        # Straight into the queue.\n                        self.new_uuids.append(new_uuid)\n                        good += 1\n            except Exception as e:\n                logger.error(e)\n                flash(gettext(\"Error processing row number {}, check all cell data types are correct, row was skipped.\").format(row_id), 'error')\n            else:\n                row_id += 1\n\n        flash(gettext(\"{} imported from Wachete .xlsx in {:.2f}s\").format(len(self.new_uuids), time.time() - now))\n\n\nclass import_xlsx_custom(Importer):\n\n    def run(self,\n            data,\n            flash,\n            datastore,\n            ):\n\n        good = 0\n        now = time.time()\n        self.new_uuids = []\n\n        from openpyxl import load_workbook\n\n        try:\n            wb = load_workbook(data)\n        except Exception as e:\n            # @todo correct except\n            flash(gettext(\"Unable to read export XLSX file, something wrong with the file?\"), 'error')\n            return\n\n        # @todo cehck atleast 2 rows, same in other method\n        from changedetectionio.forms import validate_url\n        row_i = 1\n\n        try:\n            for row in wb.active.iter_rows():\n                url = None\n                tags = None\n                extras = {}\n\n                for cell in row:\n                    if not self.import_profile.get(cell.col_idx):\n                        continue\n                    if not cell.value:\n                        continue\n\n                    cell_map = self.import_profile.get(cell.col_idx)\n\n                    cell_val = str(cell.value).strip()  # could be bool\n\n                    if cell_map == 'url':\n                        url = cell.value.strip()\n                        try:\n                            validate_url(url)\n                        except ValidationError as e:\n                            logger.error(f\">> Import URL error {url} {str(e)}\")\n                            flash(gettext(\"Error processing row number {}, URL value was incorrect, row was skipped.\").format(row_i), 'error')\n                            # Don't bother processing anything else on this row\n                            url = None\n                            break\n                    elif cell_map == 'tag':\n                        tags = cell.value.strip()\n                    elif cell_map == 'include_filters':\n                        # @todo validate?\n                        extras['include_filters'] = [cell.value.strip()]\n                    elif cell_map == 'interval_minutes':\n                        hours, minutes = divmod(int(cell_val), 60)\n                        days, hours = divmod(hours, 24)\n                        weeks, days = divmod(days, 7)\n                        extras['time_between_check'] = {'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': 0}\n                    else:\n                        extras[cell_map] = cell_val\n\n                # At minimum a URL is required.\n                if url:\n                    new_uuid = datastore.add_watch(url=url,\n                                                   extras=extras,\n                                                   tag=tags,\n                                                   save_immediately=False)\n                    if new_uuid:\n                        # Straight into the queue.\n                        self.new_uuids.append(new_uuid)\n                        good += 1\n        except Exception as e:\n            logger.error(e)\n            flash(gettext(\"Error processing row number {}, check all cell data types are correct, row was skipped.\").format(row_i), 'error')\n        else:\n            row_i += 1\n\n        flash(gettext(\"{} imported from custom .xlsx in {:.2f}s\").format(len(self.new_uuids), time.time() - now))"
  },
  {
    "path": "changedetectionio/blueprint/imports/templates/import.html",
    "content": "{% extends 'base.html' %}\n{% block content %}\n{% from '_helpers.html' import render_field %}\n<script src=\"{{url_for('static_content', group='js', filename='tabs.js')}}\" defer></script>\n<div class=\"edit-form monospaced-textarea\">\n\n    <div class=\"tabs collapsable\">\n        <ul>\n            <li class=\"tab\" id=\"\"><a href=\"#url-list\">{{ _('URL List') }}</a></li>\n            <li class=\"tab\"><a href=\"#distill-io\">{{ _('Distill.io') }}</a></li>\n            <li class=\"tab\"><a href=\"#xlsx\">{{ _('.XLSX & Wachete') }}</a></li>\n        </ul>\n    </div>\n\n    <div class=\"box-wrap inner\">\n        <form class=\"pure-form\" action=\"{{url_for('imports.import_page')}}\" method=\"POST\" enctype=\"multipart/form-data\">\n            <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\">\n            <div class=\"tab-pane-inner\" id=\"url-list\">\n\n                <p>\n                {{ _('Restoring changedetection.io backups is in the') }}<a href=\"{{ url_for('backups.restore.restore') }}\"> {{ _('backups section') }}</a>.\n                <br>\n                </p>\n                <div class=\"pure-control-group\">\n                        {{ _('Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):') }}\n                        <br>\n                        <p><strong>{{ _('Example:') }}  </strong><code>https://example.com tag1, tag2, last tag</code></p>\n                        {{ _('URLs which do not pass validation will stay in the textarea.') }}\n                </div>\n                {{ render_field(form.processor, class=\"processor\") }}\n                \n                <div class=\"pure-control-group\">\n                    <textarea name=\"urls\" class=\"pure-input-1-2\" placeholder=\"https://\"\n                              style=\"width: 100%;\n                                font-family:monospace;\n                                white-space: pre;\n                                overflow-wrap: normal;\n                                overflow-x: scroll;\" rows=\"25\">{{ import_url_list_remaining }}</textarea>\n                 </div>\n                 <div id=\"quick-watch-processor-type\"></div>\n\n            </div>\n\n            <div class=\"tab-pane-inner\" id=\"distill-io\">\n                    <div class=\"pure-control-group\">\n                        {{ _('Copy and Paste your Distill.io watch \\'export\\' file, this should be a JSON file.') }}<br>\n                        {{ _('This is') }} <i>{{ _('experimental') }}</i>, {{ _('supported fields are') }} <code>name</code>, <code>uri</code>, <code>tags</code>, <code>config:selections</code>, {{ _('the rest (including') }} <code>schedule</code>) {{ _('are ignored.') }}\n                        <br>\n                        <p>\n                        {{ _('How to export?') }} <a href=\"https://distill.io/docs/web-monitor/how-export-and-import-monitors/\">https://distill.io/docs/web-monitor/how-export-and-import-monitors/</a><br>\n                        {{ _('Be sure to set your default fetcher to Chrome if required.') }}<br>\n                        </p>\n                    </div>\n                    <textarea name=\"distill-io\" class=\"pure-input-1-2\" style=\"width: 100%;\n                                font-family:monospace;\n                                white-space: pre;\n                                overflow-wrap: normal;\n                                overflow-x: scroll;\" placeholder=\"Example Distill.io JSON export file\n\n{\n    &quot;client&quot;: {\n        &quot;local&quot;: 1\n    },\n    &quot;data&quot;: [\n        {\n            &quot;name&quot;: &quot;Unraid | News&quot;,\n            &quot;uri&quot;: &quot;https://unraid.net/blog&quot;,\n            &quot;config&quot;: &quot;{\\&quot;selections\\&quot;:[{\\&quot;frames\\&quot;:[{\\&quot;index\\&quot;:0,\\&quot;excludes\\&quot;:[],\\&quot;includes\\&quot;:[{\\&quot;type\\&quot;:\\&quot;xpath\\&quot;,\\&quot;expr\\&quot;:\\&quot;(//div[@id='App']/div[contains(@class,'flex')]/main[contains(@class,'relative')]/section[contains(@class,'relative')]/div[@class='container']/div[contains(@class,'flex')]/div[contains(@class,'w-full')])[1]\\&quot;}]}],\\&quot;dynamic\\&quot;:true,\\&quot;delay\\&quot;:2}],\\&quot;ignoreEmptyText\\&quot;:true,\\&quot;includeStyle\\&quot;:false,\\&quot;dataAttr\\&quot;:\\&quot;text\\&quot;}&quot;,\n            &quot;tags&quot;: [],\n            &quot;content_type&quot;: 2,\n            &quot;state&quot;: 40,\n            &quot;schedule&quot;: &quot;{\\&quot;type\\&quot;:\\&quot;INTERVAL\\&quot;,\\&quot;params\\&quot;:{\\&quot;interval\\&quot;:4447}}&quot;,\n            &quot;ts&quot;: &quot;2022-03-27T15:51:15.667Z&quot;\n        }\n    ]\n}\n\" rows=\"25\">{{ original_distill_json }}</textarea>\n\n            </div>\n            <div class=\"tab-pane-inner\" id=\"xlsx\">\n            <fieldset>\n                <div class=\"pure-control-group\">\n                {{ render_field(form.xlsx_file, class=\"processor\") }}\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_field(form.file_mapping, class=\"processor\") }}\n                </div>\n            </fieldset>\n                <div class=\"pure-control-group\">\n                <span class=\"pure-form-message-inline\">\n                    {{ _('Table of custom column and data types mapping for the') }} <strong>{{ _('Custom mapping') }}</strong> {{ _('File mapping type.') }}\n                </span>\n                    <table style=\"border: 1px solid #aaa; padding: 0.5rem; border-radius: 4px;\">\n                        <tr>\n                            <td><strong>{{ _('Column #') }}</strong></td>\n                            {% for n in range(4) %}\n                                <td><input type=\"number\" name=\"custom_xlsx[col_{{n}}]\" style=\"width: 4rem;\" min=\"1\"></td>\n                            {%  endfor %}\n                        </tr>\n                        <tr>\n                            <td><strong>{{ _('Type') }}</strong></td>\n                            {% for n in range(4) %}\n                                <td><select name=\"custom_xlsx[col_type_{{n}}]\">\n                                    <option value=\"\" style=\"color: #aaa\"> -- {{ _('none') }} --</option>\n                                    <option value=\"url\">{{ _('URL') }}</option>\n                                    <option value=\"title\">{{ _('Title') }}</option>\n                                    <option value=\"include_filters\">{{ _('CSS/xPath filter') }}</option>\n                                    <option value=\"tag\">{{ _('Group / Tag name(s)') }}</option>\n                                    <option value=\"interval_minutes\">{{ _('Recheck time (minutes)') }}</option>\n                                </select></td>\n                            {%  endfor %}\n                        </tr>\n                    </table>\n                </div>\n            </div>\n            <button type=\"submit\" class=\"pure-button pure-input-1-2 pure-button-primary\">{{ _('Import') }}</button>\n\n        </form>\n\n    </div>\n</div>\n\n{% endblock %}"
  },
  {
    "path": "changedetectionio/blueprint/price_data_follower/__init__.py",
    "content": "\nfrom changedetectionio.strtobool import strtobool\nfrom flask import Blueprint, flash, redirect, url_for\nfrom flask_login import login_required\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio import queuedWatchMetaData\nfrom changedetectionio import worker_pool\nfrom queue import PriorityQueue\n\nPRICE_DATA_TRACK_ACCEPT = 'accepted'\nPRICE_DATA_TRACK_REJECT = 'rejected'\n\ndef construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue):\n\n    price_data_follower_blueprint = Blueprint('price_data_follower', __name__)\n\n    @login_required\n    @price_data_follower_blueprint.route(\"/<uuid_str:uuid>/accept\", methods=['GET'])\n    def accept(uuid):\n        datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT\n        datastore.data['watching'][uuid]['processor'] = 'restock_diff'\n        datastore.data['watching'][uuid].clear_watch()\n        datastore.data['watching'][uuid].commit()\n        worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n        return redirect(url_for(\"watchlist.index\"))\n\n    @login_required\n    @price_data_follower_blueprint.route(\"/<uuid_str:uuid>/reject\", methods=['GET'])\n    def reject(uuid):\n        datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT\n        datastore.data['watching'][uuid].commit()\n        return redirect(url_for(\"watchlist.index\"))\n\n\n    return price_data_follower_blueprint\n\n\n"
  },
  {
    "path": "changedetectionio/blueprint/rss/__init__.py",
    "content": "from copy import deepcopy\nfrom loguru import logger\n\nfrom changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\nfrom changedetectionio.notification import valid_notification_formats\n\nRSS_CONTENT_FORMAT_DEFAULT = 'text'\n\n# Some stuff not related\nRSS_FORMAT_TYPES = deepcopy(valid_notification_formats)\nif RSS_FORMAT_TYPES.get('markdown'):\n    del RSS_FORMAT_TYPES['markdown']\n\nif RSS_FORMAT_TYPES.get(USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH):\n    del RSS_FORMAT_TYPES[USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH]\n\nif not RSS_FORMAT_TYPES.get(RSS_CONTENT_FORMAT_DEFAULT):\n    logger.critical(f\"RSS_CONTENT_FORMAT_DEFAULT not in the acceptable list {RSS_CONTENT_FORMAT_DEFAULT}\")\n\nRSS_TEMPLATE_TYPE_OPTIONS = {'system_default': 'System default', 'notification_body': 'Notification body'}\n\n# @note: We use <pre> because nearly all RSS readers render only HTML (Thunderbird for example cant do just plaintext)\nRSS_TEMPLATE_PLAINTEXT_DEFAULT = \"<pre>{{watch_label}} had a change.\\n\\n{{diff}}\\n</pre>\"\n\n# @todo add some [edit]/[history]/[goto] etc links\n# @todo need {{watch_edit_link}} + delete + history link token\nRSS_TEMPLATE_HTML_DEFAULT = \"<html><body>\\n<h4><a href=\\\"{{watch_url}}\\\">{{watch_label}}</a></h4>\\n<p>{{diff}}</p>\\n</body></html>\\n\"\n"
  },
  {
    "path": "changedetectionio/blueprint/rss/_util.py",
    "content": "\"\"\"\nUtility functions for RSS feed generation.\n\"\"\"\n\nfrom changedetectionio.notification.handler import process_notification\nfrom changedetectionio.notification_service import NotificationContextData, _check_cascading_vars\nfrom loguru import logger\nimport datetime\nimport pytz\nimport re\n\n\nBAD_CHARS_REGEX = r'[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]'\n\n\ndef scan_invalid_chars_in_rss(content):\n    \"\"\"\n    Scan for invalid characters in RSS content.\n    Returns True if invalid characters are found.\n    \"\"\"\n    for match in re.finditer(BAD_CHARS_REGEX, content):\n        i = match.start()\n        bad_char = content[i]\n        hex_value = f\"0x{ord(bad_char):02x}\"\n        # Grab context\n        start = max(0, i - 20)\n        end = min(len(content), i + 21)\n        context = content[start:end].replace('\\n', '\\\\n').replace('\\r', '\\\\r')\n        logger.warning(f\"Invalid char {hex_value} at pos {i}: ...{context}...\")\n        # First match is enough\n        return True\n\n    return False\n\n\ndef clean_entry_content(content):\n    \"\"\"\n    Remove invalid characters from RSS content.\n    \"\"\"\n    cleaned = re.sub(BAD_CHARS_REGEX, '', content)\n    return cleaned\n\n\ndef generate_watch_guid(watch, timestamp):\n    \"\"\"\n    Generate a unique GUID for a watch RSS entry.\n\n    Args:\n        watch: The watch object\n        timestamp: The timestamp of the specific change this entry represents\n    \"\"\"\n    return f\"{watch['uuid']}/{timestamp}\"\n\n\ndef validate_rss_token(datastore, request):\n    \"\"\"\n    Validate the RSS access token from the request.\n\n    Returns:\n        tuple: (is_valid, error_response) where error_response is None if valid\n    \"\"\"\n    app_rss_token = datastore.data['settings']['application'].get('rss_access_token')\n    rss_url_token = request.args.get('token')\n\n    if rss_url_token != app_rss_token:\n        return False, (\"Access denied, bad token\", 403)\n\n    return True, None\n\n\ndef get_rss_template(datastore, watch, rss_content_format, default_html, default_plaintext):\n    \"\"\"Get the appropriate template for RSS content.\"\"\"\n    if datastore.data['settings']['application'].get('rss_template_type') == 'notification_body':\n        return _check_cascading_vars(datastore=datastore, var_name='notification_body', watch=watch)\n\n    override = datastore.data['settings']['application'].get('rss_template_override')\n    if override and override.strip():\n        return override\n    elif 'text' in rss_content_format:\n        return default_plaintext\n    else:\n        return default_html\n\n\ndef get_watch_label(datastore, watch):\n    \"\"\"Get the label for a watch based on settings.\"\"\"\n    if datastore.data['settings']['application']['ui'].get('use_page_title_in_list') or watch.get('use_page_title_in_list'):\n        return watch.label\n    else:\n        return watch.get('url')\n\n\ndef add_watch_categories(fe, watch, datastore):\n    \"\"\"Add category tags to a feed entry based on watch tags.\"\"\"\n    for tag_uuid in watch.get('tags', []):\n        tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)\n        if tag and tag.get('title'):\n            fe.category(term=tag.get('title'))\n\n\ndef build_notification_context(watch, timestamp_from, timestamp_to, watch_label,\n                               n_body_template, rss_content_format):\n    \"\"\"Build the notification context object.\"\"\"\n    return NotificationContextData(initial_data={\n        'notification_urls': ['null://just-sending-a-null-test-for-the-render-in-RSS'],\n        'notification_body': n_body_template,\n        'timestamp_to': timestamp_to,\n        'timestamp_from': timestamp_from,\n        'watch_label': watch_label,\n        'notification_format': rss_content_format\n    })\n\n\ndef render_notification(n_object, notification_service, watch, datastore,\n                       date_index_from=None, date_index_to=None):\n    \"\"\"Process and render the notification content.\"\"\"\n    kwargs = {'n_object': n_object, 'watch': watch}\n\n    if date_index_from is not None and date_index_to is not None:\n        kwargs['date_index_from'] = date_index_from\n        kwargs['date_index_to'] = date_index_to\n\n    n_object = notification_service.queue_notification_for_watch(**kwargs)\n    n_object['watch_mime_type'] = None\n\n    res = process_notification(n_object=n_object, datastore=datastore)\n    return res[0]\n\n\ndef populate_feed_entry(fe, watch, content, guid, timestamp, link=None, title_suffix=None):\n    \"\"\"Populate a feed entry with content and metadata.\"\"\"\n    watch_label = watch.get('url')  # Already determined by caller\n\n    # Set link\n    if link:\n        fe.link(link=link)\n\n    # Set title\n    if title_suffix:\n        fe.title(title=f\"{watch_label} - {title_suffix}\")\n    else:\n        fe.title(title=watch_label)\n\n    # Clean and set content\n    if scan_invalid_chars_in_rss(content):\n        content = clean_entry_content(content)\n    fe.content(content=content, type='CDATA')\n\n    # Set GUID\n    fe.guid(guid, permalink=False)\n\n    # Set pubDate using the timestamp of this specific change\n    dt = datetime.datetime.fromtimestamp(int(timestamp))\n    dt = dt.replace(tzinfo=pytz.UTC)\n    fe.pubDate(dt)\n\n"
  },
  {
    "path": "changedetectionio/blueprint/rss/blueprint.py",
    "content": "\nfrom changedetectionio.store import ChangeDetectionStore\nfrom flask import Blueprint\n\nfrom . import tag as tag_routes\nfrom . import main_feed\nfrom . import single_watch\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    \"\"\"\n    Construct and configure the RSS blueprint with all routes.\n\n    Args:\n        datastore: The ChangeDetectionStore instance\n\n    Returns:\n        The configured Flask blueprint\n    \"\"\"\n    rss_blueprint = Blueprint('rss', __name__)\n\n    # Register all route modules\n    main_feed.construct_main_feed_routes(rss_blueprint, datastore)\n    single_watch.construct_single_watch_routes(rss_blueprint, datastore)\n    tag_routes.construct_tag_routes(rss_blueprint, datastore)\n\n    return rss_blueprint"
  },
  {
    "path": "changedetectionio/blueprint/rss/main_feed.py",
    "content": "from flask import make_response, request, url_for, redirect\n\n\n\ndef construct_main_feed_routes(rss_blueprint, datastore):\n    \"\"\"\n    Construct the main RSS feed routes.\n\n    Args:\n        rss_blueprint: The Flask blueprint to add routes to\n        datastore: The ChangeDetectionStore instance\n    \"\"\"\n\n    # Some RSS reader situations ended up with rss/ (forward slash after RSS) due\n    # to some earlier blueprint rerouting work, it should goto feed.\n    @rss_blueprint.route(\"/\", methods=['GET'])\n    def extraslash():\n        return redirect(url_for('rss.feed'))\n\n    # Import the login decorator if needed\n    # from changedetectionio.auth_decorator import login_optionally_required\n    @rss_blueprint.route(\"\", methods=['GET'])\n    def feed():\n        from feedgen.feed import FeedGenerator\n        from loguru import logger\n        import time\n\n        from . import RSS_TEMPLATE_HTML_DEFAULT, RSS_TEMPLATE_PLAINTEXT_DEFAULT\n        from ._util import (validate_rss_token, generate_watch_guid, get_rss_template,\n                           get_watch_label, build_notification_context, render_notification,\n                           populate_feed_entry, add_watch_categories)\n        from ...notification_service import NotificationService\n\n        now = time.time()\n\n        # Validate token\n        is_valid, error = validate_rss_token(datastore, request)\n        if not is_valid:\n            return error\n\n        rss_content_format = datastore.data['settings']['application'].get('rss_content_format')\n\n        limit_tag = request.args.get('tag', '').lower().strip()\n        # Be sure limit_tag is a uuid\n        for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():\n            if limit_tag == tag.get('title', '').lower().strip():\n                limit_tag = uuid\n\n        # Sort by last_changed and add the uuid which is usually the key..\n        sorted_watches = []\n\n        # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away\n        for uuid, watch in datastore.data['watching'].items():\n            # @todo tag notification_muted skip also (improve Watch model)\n            if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'):\n                continue\n            if limit_tag and not limit_tag in watch['tags']:\n                continue\n            sorted_watches.append(watch)\n\n        sorted_watches.sort(key=lambda x: x.last_changed, reverse=False)\n\n        fg = FeedGenerator()\n        fg.title('changedetection.io')\n        fg.description('Feed description')\n        fg.link(href='https://changedetection.io')\n        notification_service = NotificationService(datastore=datastore, notification_q=False)\n\n        for watch in sorted_watches:\n\n            dates = list(watch.history.keys())\n            # Re #521 - Don't bother processing this one if theres less than 2 snapshots, means we never had a change detected.\n            if len(dates) < 2:\n                continue\n\n            if not watch.viewed:\n                # Re #239 - GUID needs to be individual for each event\n                # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228)\n                watch_label = get_watch_label(datastore, watch)\n                timestamp_to = dates[-1]\n                timestamp_from = dates[-2]\n                guid = generate_watch_guid(watch, timestamp_to)\n                # Because we are called via whatever web server, flask should figure out the right path\n                diff_link = {'href': url_for('ui.ui_diff.diff_history_page', uuid=watch['uuid'], _external=True)}\n\n                # Get template and build notification context\n                n_body_template = get_rss_template(datastore, watch, rss_content_format,\n                                                   RSS_TEMPLATE_HTML_DEFAULT, RSS_TEMPLATE_PLAINTEXT_DEFAULT)\n\n                n_object = build_notification_context(watch, timestamp_from, timestamp_to,\n                                                     watch_label, n_body_template, rss_content_format)\n\n                # Render notification\n                res = render_notification(n_object, notification_service, watch, datastore)\n\n                # Create and populate feed entry\n                fe = fg.add_entry()\n                populate_feed_entry(fe, watch, res['body'], guid, timestamp_to, link=diff_link)\n                fe.title(title=watch_label)  # Override title to not include suffix\n                add_watch_categories(fe, watch, datastore)\n\n        response = make_response(fg.rss_str())\n        response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8')\n        logger.trace(f\"RSS generated in {time.time() - now:.3f}s\")\n        return response\n"
  },
  {
    "path": "changedetectionio/blueprint/rss/single_watch.py",
    "content": "\n\ndef construct_single_watch_routes(rss_blueprint, datastore):\n    \"\"\"\n    Construct RSS feed routes for single watches.\n\n    Args:\n        rss_blueprint: The Flask blueprint to add routes to\n        datastore: The ChangeDetectionStore instance\n    \"\"\"\n\n    @rss_blueprint.route(\"/watch/<uuid_str:uuid>\", methods=['GET'])\n    def rss_single_watch(uuid):\n        import time\n\n        from flask import make_response, request, Response\n        from flask_babel import lazy_gettext as _l\n        from feedgen.feed import FeedGenerator\n        from loguru import logger\n\n        from . import RSS_TEMPLATE_HTML_DEFAULT, RSS_TEMPLATE_PLAINTEXT_DEFAULT\n        from ._util import (validate_rss_token, get_rss_template, get_watch_label,\n                           build_notification_context, render_notification,\n                           populate_feed_entry, add_watch_categories)\n        from ...notification_service import NotificationService\n\n        \"\"\"\n        Display the most recent changes for a single watch as RSS feed.\n        Returns RSS XML with multiple entries showing diffs between consecutive snapshots.\n        The number of entries is controlled by the rss_diff_length setting.\n        \"\"\"\n        now = time.time()\n\n        # Validate token\n        is_valid, error = validate_rss_token(datastore, request)\n        if not is_valid:\n            return error\n\n        rss_content_format = datastore.data['settings']['application'].get('rss_content_format')\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n        # Get the watch by UUID\n        watch = datastore.data['watching'].get(uuid)\n        if not watch:\n            return Response(_l(\"Watch with UUID %(uuid)s not found\", uuid=uuid), status=404, mimetype='text/plain')\n\n        # Check if watch has at least 2 history snapshots\n        dates = list(watch.history.keys())\n        if len(dates) < 2:\n            return Response(_l(\"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\", uuid=uuid), status=400, mimetype='text/plain')\n\n        # Get the number of diffs to include (default: 5)\n        rss_diff_length = datastore.data['settings']['application'].get('rss_diff_length', 5)\n\n        # Calculate how many diffs we can actually show (limited by available history)\n        # We need at least 2 snapshots to create 1 diff\n        max_possible_diffs = len(dates) - 1\n        num_diffs = min(rss_diff_length, max_possible_diffs) if rss_diff_length > 0 else max_possible_diffs\n\n        # Create RSS feed\n        fg = FeedGenerator()\n\n        # Set title: use \"label (url)\" if label differs from url, otherwise just url\n        watch_url = watch.get('url', '')\n        watch_label = get_watch_label(datastore, watch)\n\n        if watch_label != watch_url:\n            feed_title = f'changedetection.io - {watch_label} ({watch_url})'\n        else:\n            feed_title = f'changedetection.io - {watch_url}'\n\n        fg.title(feed_title)\n        fg.description('Changes')\n        fg.link(href='https://changedetection.io')\n\n        # Loop through history and create RSS entries for each diff\n        # Add entries in reverse order because feedgen reverses them\n        # This way, the newest change appears first in the final RSS\n\n        notification_service = NotificationService(datastore=datastore, notification_q=False)\n        for i in range(num_diffs - 1, -1, -1):\n            # Calculate indices for this diff (working backwards from newest)\n            # i=0: compare dates[-2] to dates[-1] (most recent change)\n            # i=1: compare dates[-3] to dates[-2] (previous change)\n            # etc.\n            date_index_to = -(i + 1)\n            date_index_from = -(i + 2)\n            timestamp_to = dates[date_index_to]\n            timestamp_from = dates[date_index_from]\n\n            # Get template and build notification context\n            n_body_template = get_rss_template(datastore, watch, rss_content_format,\n                                               RSS_TEMPLATE_HTML_DEFAULT, RSS_TEMPLATE_PLAINTEXT_DEFAULT)\n\n            n_object = build_notification_context(watch, timestamp_from, timestamp_to,\n                                                 watch_label, n_body_template, rss_content_format)\n\n            # Render notification with date indices\n            res = render_notification(n_object, notification_service, watch, datastore,\n                                     date_index_from, date_index_to)\n\n            # Create and populate feed entry\n            guid = f\"{uuid}/{timestamp_to}\"\n            fe = fg.add_entry()\n            title_suffix = f\"Change @ {res['original_context']['change_datetime']}\"\n            populate_feed_entry(fe, watch, res.get('body', ''), guid, timestamp_to,\n                              link={'href': watch.get('url')}, title_suffix=title_suffix)\n            add_watch_categories(fe, watch, datastore)\n\n        response = make_response(fg.rss_str())\n        response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8')\n        logger.debug(f\"RSS Single watch built in {time.time()-now:.2f}s\")\n\n        return response\n"
  },
  {
    "path": "changedetectionio/blueprint/rss/tag.py",
    "content": "def construct_tag_routes(rss_blueprint, datastore):\n    \"\"\"\n    Construct RSS feed routes for tags.\n\n    Args:\n        rss_blueprint: The Flask blueprint to add routes to\n        datastore: The ChangeDetectionStore instance\n    \"\"\"\n\n    @rss_blueprint.route(\"/tag/<uuid_str:tag_uuid>\", methods=['GET'])\n    def rss_tag_feed(tag_uuid):\n\n        from flask import make_response, request, url_for\n        from feedgen.feed import FeedGenerator\n\n        from . import RSS_TEMPLATE_HTML_DEFAULT, RSS_TEMPLATE_PLAINTEXT_DEFAULT\n        from ._util import (validate_rss_token, generate_watch_guid, get_rss_template,\n                           get_watch_label, build_notification_context, render_notification,\n                           populate_feed_entry, add_watch_categories)\n        from ...notification_service import NotificationService\n\n        \"\"\"\n        Display an RSS feed for all unviewed watches that belong to a specific tag.\n        Returns RSS XML with entries for each unviewed watch with sufficient history.\n        \"\"\"\n        # Validate token\n        is_valid, error = validate_rss_token(datastore, request)\n        if not is_valid:\n            return error\n\n        rss_content_format = datastore.data['settings']['application'].get('rss_content_format')\n\n        # Verify tag exists\n        tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)\n        if not tag:\n            return f\"Tag with UUID {tag_uuid} not found\", 404\n\n        tag_title = tag.get('title', 'Unknown Tag')\n\n        # Create RSS feed\n        fg = FeedGenerator()\n        fg.title(f'changedetection.io - {tag_title}')\n        fg.description(f'Changes for watches tagged with {tag_title}')\n        fg.link(href='https://changedetection.io')\n        notification_service = NotificationService(datastore=datastore, notification_q=False)\n        # Find all watches with this tag\n        for uuid, watch in datastore.data['watching'].items():\n            #@todo  This is wrong, it needs to sort by most recently changed and then limit it  datastore.data['watching'].items().sorted(?)\n            # So get all watches in this tag then sort\n\n            # Skip if watch doesn't have this tag\n            if tag_uuid not in watch.get('tags', []):\n                continue\n\n            # Skip muted watches if configured\n            if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'):\n                continue\n\n            # Check if watch has at least 2 history snapshots\n            dates = list(watch.history.keys())\n            if len(dates) < 2:\n                continue\n\n            # Only include unviewed watches\n            if not watch.viewed:\n                # Include a link to the diff page (use uuid from loop, don't modify watch dict)\n                diff_link = {'href': url_for('ui.ui_diff.diff_history_page', uuid=uuid, _external=True)}\n\n                # Get watch label\n                watch_label = get_watch_label(datastore, watch)\n\n                # Get template and build notification context\n                timestamp_to = dates[-1]\n                timestamp_from = dates[-2]\n\n                # Generate GUID for this entry\n                guid = generate_watch_guid(watch, timestamp_to)\n                n_body_template = get_rss_template(datastore, watch, rss_content_format,\n                                                   RSS_TEMPLATE_HTML_DEFAULT, RSS_TEMPLATE_PLAINTEXT_DEFAULT)\n\n                n_object = build_notification_context(watch, timestamp_from, timestamp_to,\n                                                     watch_label, n_body_template, rss_content_format)\n\n                # Render notification\n                res = render_notification(n_object, notification_service, watch, datastore)\n\n                # Create and populate feed entry\n                fe = fg.add_entry()\n                title_suffix = f\"Change @ {res['original_context']['change_datetime']}\"\n                populate_feed_entry(fe, watch, res['body'], guid, timestamp_to, link=diff_link, title_suffix=title_suffix)\n                add_watch_categories(fe, watch, datastore)\n\n        response = make_response(fg.rss_str())\n        response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8')\n        return response\n"
  },
  {
    "path": "changedetectionio/blueprint/settings/__init__.py",
    "content": "import os\nfrom copy import deepcopy\nfrom datetime import datetime, timedelta\nfrom zoneinfo import ZoneInfo, available_timezones\nimport secrets\nimport time\nimport flask_login\nfrom flask import Blueprint, render_template, request, redirect, url_for, flash\nfrom flask_babel import gettext\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.auth_decorator import login_optionally_required\n\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    settings_blueprint = Blueprint('settings', __name__, template_folder=\"templates\")\n\n    @settings_blueprint.route(\"\", methods=['GET', \"POST\"])\n    @login_optionally_required\n    def settings_page():\n        from changedetectionio import forms\n        from changedetectionio.pluggy_interface import (\n            get_plugin_settings_tabs,\n            load_plugin_settings,\n            save_plugin_settings\n        )\n\n\n        default = deepcopy(datastore.data['settings'])\n        if datastore.proxy_list is not None:\n            available_proxies = list(datastore.proxy_list.keys())\n            # When enabled\n            system_proxy = datastore.data['settings']['requests']['proxy']\n            # In the case it doesnt exist anymore\n            if not system_proxy in available_proxies:\n                system_proxy = None\n\n            default['requests']['proxy'] = system_proxy if system_proxy is not None else available_proxies[0]\n            # Used by the form handler to keep or remove the proxy settings\n            default['proxy_list'] = available_proxies[0]\n\n        # Don't use form.data on POST so that it doesnt overrid the checkbox status from the POST status\n        form = forms.globalSettingsForm(formdata=request.form if request.method == 'POST' else None,\n                                        data=default,\n                                        extra_notification_tokens=datastore.get_unique_notification_tokens_available()\n                                        )\n\n        # Remove the last option 'System default'\n        form.application.form.notification_format.choices.pop()\n\n        if datastore.proxy_list is None:\n            # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead\n            del form.requests.form.proxy\n        else:\n            form.requests.form.proxy.choices = []\n            for p in datastore.proxy_list:\n                form.requests.form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))\n\n        if request.method == 'POST':\n            # Password unset is a GET, but we can lock the session to a salted env password to always need the password\n            if form.application.form.data.get('removepassword_button', False):\n                # SALTED_PASS means the password is \"locked\" to what we set in the Env var\n                if not os.getenv(\"SALTED_PASS\", False):\n                    datastore.remove_password()\n                    flash(gettext(\"Password protection removed.\"), 'notice')\n                    flask_login.logout_user()\n                    return redirect(url_for('settings.settings_page'))\n\n            if form.validate():\n                # Don't set password to False when a password is set - should be only removed with the `removepassword` button\n                app_update = dict(deepcopy(form.data['application']))\n\n                # Never update password with '' or False (Added by wtforms when not in submission)\n                if 'password' in app_update and not app_update['password']:\n                    del (app_update['password'])\n\n                datastore.data['settings']['application'].update(app_update)\n\n                # Handle dynamic worker count adjustment\n                old_worker_count = datastore.data['settings']['requests'].get('workers', 1)\n                new_worker_count = form.data['requests'].get('workers', 1)\n\n                datastore.data['settings']['requests'].update(form.data['requests'])\n                datastore.commit()\n\n                # Clear all checksums to force reprocessing with new settings\n                # Global settings can affect watch behavior (filters, rendering, etc.)\n                datastore.clear_all_last_checksums()\n\n                # Adjust worker count if it changed\n                if new_worker_count != old_worker_count:\n                    from changedetectionio import worker_pool\n                    from changedetectionio.flask_app import update_q, notification_q, app, datastore as ds\n\n                    # Check CPU core availability and warn if worker count is high\n                    cpu_count = os.cpu_count()\n                    if cpu_count and new_worker_count >= (cpu_count * 0.9):\n                        flash(gettext(\"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\").format(\n                            new_worker_count, cpu_count), 'warning')\n\n                    result = worker_pool.adjust_async_worker_count(\n                        new_count=new_worker_count,\n                        update_q=update_q,\n                        notification_q=notification_q,\n                        app=app,\n                        datastore=ds\n                    )\n\n                    if result['status'] == 'success':\n                        flash(gettext(\"Worker count adjusted: {}\").format(result['message']), 'notice')\n                    elif result['status'] == 'not_supported':\n                        flash(gettext(\"Dynamic worker adjustment not supported for sync workers\"), 'warning')\n                    elif result['status'] == 'error':\n                        flash(gettext(\"Error adjusting workers: {}\").format(result['message']), 'error')\n\n                if not os.getenv(\"SALTED_PASS\", False) and len(form.application.form.password.encrypted_password):\n                    datastore.data['settings']['application']['password'] = form.application.form.password.encrypted_password\n                    datastore.commit()\n                    flash(gettext(\"Password protection enabled.\"), 'notice')\n                    flask_login.logout_user()\n                    return redirect(url_for('watchlist.index'))\n\n                # Also save plugin settings from the same form submission\n                plugin_tabs_list = get_plugin_settings_tabs()\n                for tab in plugin_tabs_list:\n                    plugin_id = tab['plugin_id']\n                    form_class = tab['form_class']\n\n                    # Instantiate plugin form with POST data\n                    plugin_form = form_class(formdata=request.form)\n\n                    # Save plugin settings (validation is optional for plugins)\n                    if plugin_form.data:\n                        save_plugin_settings(datastore.datastore_path, plugin_id, plugin_form.data)\n\n                flash(gettext(\"Settings updated.\"))\n\n            else:\n                flash(gettext(\"An error occurred, please see below.\"), \"error\")\n\n        # Convert to ISO 8601 format, all date/time relative events stored as UTC time\n        utc_time = datetime.now(ZoneInfo(\"UTC\")).isoformat()\n\n        # Get active plugins\n        from changedetectionio.pluggy_interface import get_active_plugins\n        import sys\n        active_plugins = get_active_plugins()\n        python_version = f\"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\"\n\n        # Calculate uptime in seconds\n        uptime_seconds = time.time() - datastore.start_time\n\n        # Get plugin settings tabs and instantiate forms\n        plugin_tabs = get_plugin_settings_tabs()\n        plugin_forms = {}\n\n        for tab in plugin_tabs:\n            plugin_id = tab['plugin_id']\n            form_class = tab['form_class']\n\n            # Load existing settings\n            settings = load_plugin_settings(datastore.datastore_path, plugin_id)\n\n            # Instantiate the form with existing settings\n            plugin_forms[plugin_id] = form_class(data=settings)\n\n        output = render_template(\"settings.html\",\n                                active_plugins=active_plugins,\n                                api_key=datastore.data['settings']['application'].get('api_access_token'),\n                                python_version=python_version,\n                                uptime_seconds=uptime_seconds,\n                                available_timezones=sorted(available_timezones()),\n                                emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),\n                                extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(),\n                                form=form,\n                                hide_remove_pass=os.getenv(\"SALTED_PASS\", False),\n                                min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)),\n                                settings_application=datastore.data['settings']['application'],\n                                timezone_default_config=datastore.data['settings']['application'].get('scheduler_timezone_default'),\n                                utc_time=utc_time,\n                                plugin_tabs=plugin_tabs,\n                                plugin_forms=plugin_forms,\n                                )\n\n        return output\n\n    @settings_blueprint.route(\"/reset-api-key\", methods=['GET'])\n    @login_optionally_required\n    def settings_reset_api_key():\n        secret = secrets.token_hex(16)\n        datastore.data['settings']['application']['api_access_token'] = secret\n        datastore.commit()\n        flash(gettext(\"API Key was regenerated.\"))\n        return redirect(url_for('settings.settings_page')+'#api')\n        \n    @settings_blueprint.route(\"/notification-logs\", methods=['GET'])\n    @login_optionally_required\n    def notification_logs():\n        from changedetectionio.flask_app import notification_debug_log\n        output = render_template(\"notification-log.html\",\n                               logs=notification_debug_log if len(notification_debug_log) else [\"Notification logs are empty - no notifications sent yet.\"])\n        return output\n\n    @settings_blueprint.route(\"/toggle-all-paused\", methods=['GET'])\n    @login_optionally_required\n    def toggle_all_paused():\n        current_state = datastore.data['settings']['application'].get('all_paused', False)\n        datastore.data['settings']['application']['all_paused'] = not current_state\n        datastore.commit()\n\n        if datastore.data['settings']['application']['all_paused']:\n            flash(gettext(\"Automatic scheduling paused - checks will not be queued.\"), 'notice')\n        else:\n            flash(gettext(\"Automatic scheduling resumed - checks will be queued normally.\"), 'notice')\n\n        return redirect(url_for('watchlist.index'))\n\n    @settings_blueprint.route(\"/toggle-all-muted\", methods=['GET'])\n    @login_optionally_required\n    def toggle_all_muted():\n        current_state = datastore.data['settings']['application'].get('all_muted', False)\n        datastore.data['settings']['application']['all_muted'] = not current_state\n        datastore.commit()\n\n        if datastore.data['settings']['application']['all_muted']:\n            flash(gettext(\"All notifications muted.\"), 'notice')\n        else:\n            flash(gettext(\"All notifications unmuted.\"), 'notice')\n\n        return redirect(url_for('watchlist.index'))\n\n    return settings_blueprint"
  },
  {
    "path": "changedetectionio/blueprint/settings/templates/notification-log.html",
    "content": "{% extends 'base.html' %}\n\n{% block content %}\n<div class=\"edit-form\">\n     <div class=\"inner\">\n\n         <h4 style=\"margin-top: 0px;\">{{ _('Notification debug log') }}</h4>\n                <div id=\"notification-error-log\">\n                <ul style=\"font-size: 80%; margin:0px; padding: 0 0 0 7px\">\n                {% for log in logs|reverse %}\n                    <li>{{log}}</li>\n                {% endfor %}\n                </ul>\n                </div>\n\n     </div>\n</div>\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/settings/templates/settings.html",
    "content": "{% extends 'base.html' %}\n\n{% block content %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, render_ternary_field, render_fieldlist_with_inline_errors %}\n{% from '_common_fields.html' import render_common_settings_form, show_token_placeholders %}\n<script>\n    const notification_base_url=\"{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode=\"global-settings\")}}\";\n{% if emailprefix %}\n    const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}');\n{% endif %}\n</script>\n<script src=\"{{url_for('static_content', group='js', filename='tabs.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='plugins.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='notifications.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='vis.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='global-settings.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='scheduler.js')}}\" defer></script>\n<div class=\"edit-form\">\n    <div class=\"tabs collapsable\">\n        <ul>\n            <li class=\"tab\" id=\"\"><a href=\"#general\">{{ _('General') }}</a></li>\n            <li class=\"tab\"><a href=\"#notifications\">{{ _('Notifications') }}</a></li>\n            <li class=\"tab\"><a href=\"#fetching\">{{ _('Fetching') }}</a></li>\n            <li class=\"tab\"><a href=\"#filters\">{{ _('Global Filters') }}</a></li>\n            <li class=\"tab\"><a href=\"#ui-options\">{{ _('UI Options') }}</a></li>\n            <li class=\"tab\"><a href=\"#api\">{{ _('API') }}</a></li>\n            <li class=\"tab\"><a href=\"#rss\">{{ _('RSS') }}</a></li>\n            <li class=\"tab\"><a href=\"{{ url_for('backups.create') }}\">{{ _('Backups') }}</a></li>\n            <li class=\"tab\"><a href=\"#timedate\">{{ _('Time & Date') }}</a></li>\n            <li class=\"tab\"><a href=\"#proxies\">{{ _('CAPTCHA & Proxies') }}</a></li>\n            {% if plugin_tabs %}\n                {% for tab in plugin_tabs %}\n            <li class=\"tab\"><a href=\"#plugin-{{ tab.plugin_id }}\">{{ tab.tab_label }}</a></li>\n                {% endfor %}\n            {% endif %}\n            <li class=\"tab\"><a href=\"#info\">{{ _('Info') }}</a></li>\n        </ul>\n    </div>\n    <div class=\"box-wrap inner\">\n        <form class=\"pure-form pure-form-stacked settings\" action=\"{{url_for('settings.settings_page')}}\" method=\"POST\">\n            <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\" >\n            <div class=\"tab-pane-inner\" id=\"general\">\n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.requests.form.time_between_check, class=\"time-check-widget\") }}\n\n                        <span class=\"pure-form-message-inline\">{{ _('Default recheck time for all watches, current system minimum is') }} <i>{{min_system_recheck_seconds}}</i> {{ _('seconds') }} (<a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Misc-system-settings#enviroment-variables\">{{ _('more info') }}</a>).</span>\n                            <div id=\"time-between-check-schedule\">\n                                <!-- Start Time and End Time {{ timezone_default_config }} -->\n                                <div id=\"limit-between-time\">\n                                   {{ render_time_schedule_form(form.requests, available_timezones, timezone_default_config) }}\n                                </div>\n                        </div>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class=\"filter_failure_notification_threshold_attempts\") }}\n                        <span class=\"pure-form-message-inline\">{{ _('After this many consecutive times that the CSS/xPath filter is missing, send a notification') }}\n                            <br>\n                        {{ _('Set to') }} <strong>0</strong> {{ _('to disable') }}\n                        </span>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.application.form.history_snapshot_max_length, class=\"history_snapshot_max_length\") }}\n                        <span class=\"pure-form-message-inline\">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}\n                            <br>\n                        {{ _('Set to empty to disable / no limit') }}\n                        </span>\n                    </div>\n\n                    <div class=\"pure-control-group\">\n                        {% if not hide_remove_pass %}\n                            {% if current_user.is_authenticated %}\n                                {{ render_button(form.application.form.removepassword_button) }}\n                            {% else %}\n                            {{ render_field(form.application.form.password) }}\n                            <span class=\"pure-form-message-inline\">{{ _('Password protection for your changedetection.io application.') }}</span>\n                            {% endif %}\n                        {% else %}\n                            <span class=\"pure-form-message-inline\">{{ _('Password is locked.') }}</span>\n                        {% endif %}\n                    </div>\n\n                    <div class=\"pure-control-group\">\n                        {{ render_checkbox_field(form.application.form.shared_diff_access, class=\"shared_diff_access\") }}\n                        <span class=\"pure-form-message-inline\">{{ _('Allow access to the watch change history page when password is enabled (Good for sharing the diff page)') }}</span>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }}\n                        <span class=\"pure-form-message-inline\">{{ _('When a request returns no content, or the HTML does not contain any text, is this considered a change?') }}</span>\n                    </div>\n                {% if form.requests.proxy %}\n                    <div>\n                    <br>\n                        <div class=\"inline-radio\">\n                            {{ render_field(form.requests.form.proxy, class=\"fetch-backend-proxy\") }}\n                            <span class=\"pure-form-message-inline\">{{ _('Choose a default proxy for all watches') }}</span>\n                        </div>\n                    </div>\n                {% endif %}\n\n                </fieldset>\n            </div>\n\n            <div class=\"tab-pane-inner\" id=\"notifications\">\n                <fieldset>\n                    {{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}\n                </fieldset>\n                <div class=\"pure-control-group\" id=\"notification-base-url\">\n                    {{ render_field(form.application.form.base_url, class=\"m-d\") }}\n                    <span class=\"pure-form-message-inline\">\n                        {{ _('Base URL used for the') }} <code>{{ '{{ base_url }}' }}</code> {{ _('token in notification links.') }}<br>\n                        {{ _('Default value is the system environment variable') }} '<code>BASE_URL</code>' - <a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting\">{{ _('read more here') }}</a>.\n                    </span>\n                </div>\n            </div>\n\n            <div class=\"tab-pane-inner\" id=\"fetching\">\n                <div class=\"pure-control-group inline-radio\">\n                    {{ render_field(form.application.form.fetch_backend, class=\"fetch-backend\") }}\n                    <span class=\"pure-form-message-inline\">\n                        <p>{{ _('Use the') }} <strong>{{ _('Basic') }}</strong> {{ _('method (default) where your watched sites don\\'t need Javascript to render.') }}</p>\n                        <p>{{ _('The') }} <strong>{{ _('Chrome/Javascript') }}</strong> {{ _('method requires a network connection to a running WebDriver+Chrome server, set by the ENV var') }} 'WEBDRIVER_URL'. </p>\n                    </span>\n                </div>\n                <fieldset class=\"pure-group\" id=\"webdriver-override-options\" data-visible-for=\"application-fetch_backend=html_webdriver\">\n                    <div class=\"pure-form-message-inline\">\n                        <strong>{{ _('If you\\'re having trouble waiting for the page to be fully rendered (text missing etc), try increasing the \\'wait\\' time here.') }}</strong>\n                        <br>\n                        {{ _('This will wait') }} <i>n</i> {{ _('seconds before extracting the text.') }}\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.application.form.webdriver_delay) }}\n                    </div>\n                </fieldset>\n                <div class=\"pure-control-group\">\n                    {{ render_field(form.requests.form.workers) }}\n                    {% set worker_info = get_worker_status_info() %}\n                    <span class=\"pure-form-message-inline\">{{ _('Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.') }}<br>\n                    {{ _('Currently running:') }} <strong>{{ worker_info.count }}</strong> {{ _('operational') }} {{ worker_info.type }} {{ _('workers') }}{% if worker_info.active_workers > 0 %} ({{ worker_info.active_workers }} {{ _('actively processing') }}){% endif %}.</span>\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_field(form.requests.form.jitter_seconds, class=\"jitter_seconds\") }}\n                    <span class=\"pure-form-message-inline\">{{ _('Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later') }}</span>\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_field(form.requests.form.timeout) }}\n                    <span class=\"pure-form-message-inline\">{{ _('For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.') }}</span><br>\n                </div>\n                <div class=\"pure-control-group inline-radio\">\n                    {{ render_field(form.requests.form.default_ua) }}\n                    <span class=\"pure-form-message-inline\">\n                        {{ _('Applied to all requests.') }}<br><br>\n                        {{ _('Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it\\'s important to consider') }} <a href=\"https://changedetection.io/tutorial/what-are-main-types-anti-robot-mechanisms\">{{ _('all of the ways that the browser is detected') }}</a>.\n                    </span>\n                </div>\n                <div class=\"pure-control-group\">\n                <br>\n                    {{ _('Tip:') }} <a href=\"{{ url_for('settings.settings_page')}}#proxies\">{{ _('Connect using Bright Data proxies, find out more here.') }}</a>\n                </div>\n            </div>\n\n            <div class=\"tab-pane-inner\" id=\"filters\">\n\n                    <fieldset class=\"pure-group\">\n                    {{ render_checkbox_field(form.application.form.ignore_whitespace) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.') }}<br>\n                    <i>{{ _('Note:') }}</i> {{ _('Changing this will change the status of your existing watches, possibly trigger alerts etc.') }}\n                    </span>\n                    </fieldset>\n                <fieldset class=\"pure-group\">\n                    {{ render_checkbox_field(form.application.form.render_anchor_tag_content) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Render anchor tag content, default disabled, when enabled renders links as') }} <code>(link text)[https://somesite.com]</code>\n                        <br>\n                    <i>{{ _('Note:') }}</i> {{ _('Changing this could affect the content of your existing watches, possibly trigger alerts etc.') }}\n                    </span>\n                    </fieldset>\n                    <fieldset class=\"pure-group\">\n                      {{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder=\"header\nfooter\nnav\n.stockticker\n//*[contains(text(), 'Advertisement')]\") }}\n                      <span class=\"pure-form-message-inline\">\n                        <ul>\n                          <li> {{ _('Remove HTML element(s) by CSS and XPath selectors before text conversion.') }} </li>\n                          <li> {{ _('Don\\'t paste HTML here, use only CSS and XPath selectors') }} </li>\n                          <li> {{ _('Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.') }} </li>\n                        </ul>\n                      </span>\n                    </fieldset>\n                    <fieldset class=\"pure-group\">\n                    {{ render_field(form.application.form.global_ignore_text, rows=5, placeholder=\"Some text to ignore in a line\n/some.regex\\d{2}/ for case-INsensitive regex\n                    \") }}\n                    <span class=\"pure-form-message-inline\">{{ _('Note: This is applied globally in addition to the per-watch rules.') }}</span><br>\n                    <span class=\"pure-form-message-inline\">\n                        <ul>\n                            <li>{{ _('Matching text will be') }} <strong>{{ _('ignored') }}</strong> {{ _('in the text snapshot (you can still see it but it wont trigger a change)') }}</li>\n                            <li>{{ _('Note: This is applied globally in addition to the per-watch rules.') }}</li>\n                            <li>{{ _('Each line processed separately, any line matching will be ignored (removed before creating the checksum)') }}</li>\n                            <li>{{ _('Regular Expression support, wrap the entire line in forward slash') }} <code>/regex/</code></li>\n                            <li>{{ _('Changing this will affect the comparison checksum which may trigger an alert') }}</li>\n                        </ul>\n                     </span>\n                    </fieldset>\n                    <fieldset class=\"pure-group\">\n                        {{ render_checkbox_field(form.application.form.strip_ignored_lines) }}\n                        <span class=\"pure-form-message-inline\">{{ _('Remove any text that appears in the \"Ignore text\" from the output (otherwise its just ignored for change-detection)') }}<br>\n                        <i>{{ _('Note:') }}</i> {{ _('Changing this will change the status of your existing watches, possibly trigger alerts etc.') }}\n                        </span>\n                    </fieldset>\n           </div>\n\n            <div class=\"tab-pane-inner\" id=\"api\">\n                <h4>{{ _('API Access') }}</h4>\n                <p>{{ _('Drive your changedetection.io via API, More about') }} <a href=\"https://changedetection.io/docs/api_v1/index.html\">{{ _('API access and examples here') }}</a>.</p>\n\n                <div class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.application.form.api_access_token_enabled) }}\n                    <div class=\"pure-form-message-inline\">{{ _('Restrict API access limit by using') }} <code>x-api-key</code> {{ _('header - required for the Chrome Extension to work') }}</div><br>\n                    <div class=\"pure-form-message-inline\"><br>{{ _('API Key') }} <span id=\"api-key\">{{api_key}}</span>\n                        <span style=\"display:none;\" id=\"api-key-copy\" >{{ _('copy') }}</span>\n                    </div>\n                </div>\n                <div class=\"pure-control-group\">\n                    <a href=\"{{url_for('settings.settings_reset_api_key')}}\" class=\"pure-button button-small button-cancel\">{{ _('Regenerate API key') }}</a>\n                </div>\n                <div class=\"pure-control-group\">\n                    <h4>{{ _('Chrome Extension') }}</h4>\n                    <p>{{ _('Easily add any web-page to your changedetection.io installation from within Chrome.') }}</p>\n                    <strong>{{ _('Step 1') }}</strong> {{ _('Install the extension,') }} <strong>{{ _('Step 2') }}</strong> {{ _('Navigate to this page,') }}\n                    <strong>{{ _('Step 3') }}</strong> {{ _('Open the extension from the toolbar and click') }} \"<i>{{ _('Sync API Access') }}</i>\"\n                    <p>\n                        <a id=\"chrome-extension-link\"\n                           title=\"{{ _('Try our new Chrome Extension!') }}\"\n                           href=\"https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop\">\n                            <img alt=\"{{ _('Chrome store icon') }}\" src=\"{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}\" >\n                            {{ _('Chrome Webstore') }}\n                        </a>\n                    </p>\n                </div>\n            </div>\n            <div class=\"tab-pane-inner\" id=\"rss\">\n                <div class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }}\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_field(form.application.form.rss_diff_length) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Maximum number of history snapshots to include in the watch specific RSS feed.') }}</span>\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.application.form.rss_reader_mode) }}\n                    <span class=\"pure-form-message-inline\">{{ _('For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.') }}</span>\n                </div>\n                <div class=\"pure-control-group grey-form-border\">\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.application.form.rss_content_format) }}\n                        <span class=\"pure-form-message-inline\">{{ _('Does your reader support HTML? Set it here') }}</span>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.application.form.rss_template_type) }}\n                        <span class=\"pure-form-message-inline\">{{ _('\\'System default\\' for the same template for all items, or re-use your \"Notification Body\" as the template.') }}</span>\n                    </div>\n                    <div>\n                       {{ render_field(form.application.form.rss_template_override) }}\n                        {{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info, suffix=\"-rss\") }}\n                    </div>\n                </div>\n                <br>\n\n\n            </div>\n                <div class=\"tab-pane-inner\" id=\"timedate\">\n                <div class=\"pure-control-group\">\n                    {{ _('Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.') }}\n                </div>\n                <div class=\"pure-control-group\">\n                    <p><strong>{{ _('UTC Time & Date from Server:') }}</strong> <span id=\"utc-time\" >{{ utc_time }}</span></p>\n                    <p><strong>{{ _('Local Time & Date in Browser:') }}</strong> <span class=\"local-time\" data-utc=\"{{ utc_time }}\"></span></p>\n                    <div>\n                       {{ render_field(form.application.form.scheduler_timezone_default) }}\n                        <datalist id=\"timezones\" style=\"display: none;\">\n                            {%- for timezone in available_timezones -%}<option value=\"{{ timezone }}\">{{ timezone }}</option>{%- endfor -%}\n                        </datalist>\n                    </div>\n                </div>\n            </div>\n            <div class=\"tab-pane-inner\" id=\"ui-options\">\n                <div class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class=\"open_diff_in_new_tab\") }}\n                    <span class=\"pure-form-message-inline\">{{ _('Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.') }}</span>\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.application.form.ui.form.socket_io_enabled, class=\"socket_io_enabled\") }}\n                    <span class=\"pure-form-message-inline\">{{ _('Realtime UI Updates Enabled - (Restart required if this is changed)') }}</span>\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.application.form.ui.form.favicons_enabled, class=\"\") }}\n                    <span class=\"pure-form-message-inline\">{{ _('Enable or Disable Favicons next to the watch list') }}</span>\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.application.form.ui.use_page_title_in_list) }}\n                </div>\n                <div class=\"pure-control-group\">\n                    {{ render_field(form.application.form.pager_size) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Number of items per page in the watch overview list, 0 to disable.') }}</span>\n                </div>\n\n            </div>\n            <div class=\"tab-pane-inner\" id=\"proxies\">\n                <div id=\"recommended-proxy\">\n                    <div>\n                        <img style=\"height: 2em;\" src=\"{{url_for('static_content', group='images', filename='brightdata.svg')}}\" alt=\"BrightData Proxy Provider\">\n                        <p>BrightData offer world-class proxy services, \"Data Center\" proxies are a very affordable way to proxy your requests, whilst <strong><a href=\"https://brightdata.grsm.io/n0r16zf7eivq\">WebUnlocker</a></strong> can help solve most CAPTCHAs.</p>\n                        <p>\n                            BrightData offer many <a href=\"https://brightdata.com/proxy-types\" target=\"new\">many different types of proxies</a>, it is worth reading about what is best for your use-case.\n                        </p>\n\n                        <p>\n                            When you have <a href=\"https://brightdata.grsm.io/n0r16zf7eivq\">registered</a>, enabled the required services, visit the <A href=\"https://brightdata.com/cp/api_example?\">API example page</A>, then select <strong>Python</strong>, set the country you wish to use, then copy+paste the access Proxy URL into the \"Extra Proxies\" boxes below.<br>\n                        </p>\n                        <p>\n                            The Proxy URL with BrightData should start with <code>http://brd-customer...</code>\n                        </p>\n                        <p>When you sign up using <a href=\"https://brightdata.grsm.io/n0r16zf7eivq\">https://brightdata.grsm.io/n0r16zf7eivq</a> BrightData will match any first deposit up to $150</p>\n                    </div>\n                    <div>\n                        <img style=\"height: 2em;\"\n                             src=\"{{url_for('static_content', group='images', filename='oxylabs.svg')}}\"\n                             alt=\"Oxylabs Proxy Provider\">\n                        <p>\n                            Collect public data at scale with industry-leading web scraping solutions and the world’s\n                            largest ethical proxy network.\n                        </p>\n                        <p>\n                            Oxylabs also provide a <a href=\"https://oxylabs.io/products/web-unblocker\"><strong>WebUnlocker</strong></a>\n                            proxy that bypasses sophisticated anti-bot systems, so you don’t have to.<br>\n                        </p>\n                        <p>\n                            Serve over <a href=\"https://oxylabs.io/location-proxy\">195 countries</a>, providing <a\n                                href=\"https://oxylabs.io/products/residential-proxy-pool\">Residential</a>, <a\n                                href=\"https://oxylabs.io/products/mobile-proxies\">Mobile</a> and <a\n                                href=\"https://oxylabs.io/products/rotating-isp-proxies\">ISP proxies</a> and much more.\n                        </p>\n                        <p>\n                            Use the promo code <strong>boost35</strong> with this link <a href=\"https://oxylabs.go2cloud.org/SH2d\">https://oxylabs.go2cloud.org/SH2d</a> for 35% off Residential, Mobile proxies, Web Unblocker, and Scraper APIs. Built-in proxies enable you to access data from all around the world and help overcome anti-bot solutions.\n\n                        </p>\n\n                        \n                    </div>\n                </div>\n\n                <p><strong>{{ _('Tip') }}</strong>: {{ _('\"Residential\" and \"Mobile\" proxy type can be more successful than \"Data Center\" for blocked websites.') }}</p>\n\n                <div class=\"pure-control-group\" id=\"extra-proxies-setting\">\n                {{ render_fieldlist_with_inline_errors(form.requests.form.extra_proxies) }}\n                <span class=\"pure-form-message-inline\">{{ _('\"Name\" will be used for selecting the proxy in the Watch Edit settings') }}</span><br>\n                <span class=\"pure-form-message-inline\">{{ _('SOCKS5 proxies with authentication are only supported with \\'plain requests\\' fetcher, for other fetchers you should whitelist the IP access instead') }}</span>\n                </div>\n                <div class=\"pure-control-group\" id=\"extra-browsers-setting\">\n                    <p>\n                    <span class=\"pure-form-message-inline\"><i>Extra Browsers</i> can be attached to further defeat CAPTCHA's on websites that are particularly hard to scrape.</span><br>\n                    <span class=\"pure-form-message-inline\">Simply paste the connection address into the box, <a href=\"https://changedetection.io/tutorial/using-bright-datas-scraping-browser-pass-captchas-and-other-protection-when-monitoring\">More instructions and examples here</a> </span>\n                    </p>\n                    {{ render_fieldlist_with_inline_errors(form.requests.form.extra_browsers) }}\n                </div>\n            </div>\n            {% if plugin_tabs %}\n                {% for tab in plugin_tabs %}\n            <div class=\"tab-pane-inner\" id=\"plugin-{{ tab.plugin_id }}\">\n                {% set plugin_form = plugin_forms[tab.plugin_id] %}\n                {% if tab.template_path %}\n                    {# Plugin provides custom template - include it directly (no separate form) #}\n                    {% include tab.template_path with context %}\n                {% else %}\n                    {# Default form rendering - fields only, no submit button #}\n                    <fieldset>\n                        {% for field in plugin_form %}\n                            {% if field.type != 'CSRFToken' and field.type != 'SubmitField' %}\n                        <div class=\"pure-control-group\">\n                                {% if field.type == 'BooleanField' %}\n                            {{ render_checkbox_field(field) }}\n                                {% else %}\n                            {{ render_field(field) }}\n                                {% endif %}\n                        </div>\n                            {% endif %}\n                        {% endfor %}\n                    </fieldset>\n                {% endif %}\n            </div>\n                {% endfor %}\n            {% endif %}\n            <div class=\"tab-pane-inner\" id=\"info\">\n                <p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>\n                <p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>\n                <p><strong>{{ _('Plugins active:') }}</strong></p>\n                {% if active_plugins %}\n                <ul>\n                    {% for plugin in active_plugins %}\n                    <li><strong>{{ plugin.name }}</strong> - {{ plugin.description }}</li>\n                    {% endfor %}\n                </ul>\n                {% else %}\n                <p>{{ _('No plugins active') }}</p>\n                {% endif %}\n            </div>\n            <div id=\"actions\">\n                <div class=\"pure-control-group\">\n                    {{ render_button(form.save_button) }}\n                    <a href=\"{{url_for('watchlist.index')}}\" class=\"pure-button button-cancel\">{{ _('Back') }}</a>\n                    <a href=\"{{url_for('ui.clear_all_history')}}\" class=\"pure-button button-error\">{{ _('Clear Snapshot History') }}</a>\n                </div>\n            </div>\n        </form>\n    </div>\n</div>\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/tags/README.md",
    "content": "# Groups tags\n\n## How it works\n\nWatch has a list() of tag UUID's, which relate to a config under application.settings.tags\n\nThe 'tag' is actually a watch, because they basically will eventually share 90% of the same config.\n\nSo a tag is like an abstract of a watch\n"
  },
  {
    "path": "changedetectionio/blueprint/tags/__init__.py",
    "content": "import threading\nfrom flask import Blueprint, request, render_template, flash, url_for, redirect\nfrom flask_babel import gettext\nfrom loguru import logger\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.flask_app import login_optionally_required\n\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    tags_blueprint = Blueprint('tags', __name__, template_folder=\"templates\")\n\n    @tags_blueprint.route(\"/list\", methods=['GET'])\n    @login_optionally_required\n    def tags_overview_page():\n        from .form import SingleTag\n        add_form = SingleTag(request.form)\n\n        sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])\n\n        from collections import Counter\n\n        tag_count = Counter(tag for watch in datastore.data['watching'].values() if watch.get('tags') for tag in watch['tags'])\n\n        output = render_template(\"groups-overview.html\",\n                                 app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),\n                                 available_tags=sorted_tags,\n                                 form=add_form,\n                                 tag_count=tag_count,\n                                 )\n\n        return output\n\n    @tags_blueprint.route(\"/add\", methods=['POST'])\n    @login_optionally_required\n    def form_tag_add():\n        from .form import SingleTag\n        add_form = SingleTag(request.form)\n\n        if not add_form.validate():\n            for widget, l in add_form.errors.items():\n                flash(','.join(l), 'error')\n            return redirect(url_for('tags.tags_overview_page'))\n\n        title = request.form.get('name').strip()\n\n        if datastore.tag_exists_by_name(title):\n            flash(gettext('The tag \"{}\" already exists').format(title), \"error\")\n            return redirect(url_for('tags.tags_overview_page'))\n\n        datastore.add_tag(title)\n        flash(gettext(\"Tag added\"))\n\n\n        return redirect(url_for('tags.tags_overview_page'))\n\n    @tags_blueprint.route(\"/mute/<uuid_str:uuid>\", methods=['GET'])\n    @login_optionally_required\n    def mute(uuid):\n        tag = datastore.data['settings']['application']['tags'].get(uuid)\n        if tag:\n            tag['notification_muted'] = not tag['notification_muted']\n            tag.commit()\n        return redirect(url_for('tags.tags_overview_page'))\n\n    @tags_blueprint.route(\"/delete/<uuid_str:uuid>\", methods=['GET'])\n    @login_optionally_required\n    def delete(uuid):\n        # Delete the tag from settings immediately\n        if datastore.data['settings']['application']['tags'].get(uuid):\n            del datastore.data['settings']['application']['tags'][uuid]\n\n        # Remove tag from all watches in background thread to avoid blocking\n        def remove_tag_background(tag_uuid):\n            \"\"\"Background thread to remove tag from watches - discarded after completion.\"\"\"\n            removed_count = 0\n            try:\n                for watch_uuid, watch in datastore.data['watching'].items():\n                    if watch.get('tags') and tag_uuid in watch['tags']:\n                        watch['tags'].remove(tag_uuid)\n                        watch.commit()\n                        removed_count += 1\n                logger.info(f\"Background: Tag {tag_uuid} removed from {removed_count} watches\")\n            except Exception as e:\n                logger.error(f\"Error removing tag from watches: {e}\")\n\n        # Start daemon thread\n        threading.Thread(target=remove_tag_background, args=(uuid,), daemon=True).start()\n\n        flash(gettext(\"Tag deleted, removing from watches in background\"))\n        return redirect(url_for('tags.tags_overview_page'))\n\n    @tags_blueprint.route(\"/unlink/<uuid_str:uuid>\", methods=['GET'])\n    @login_optionally_required\n    def unlink(uuid):\n        # Unlink tag from all watches in background thread to avoid blocking\n        def unlink_tag_background(tag_uuid):\n            \"\"\"Background thread to unlink tag from watches - discarded after completion.\"\"\"\n            unlinked_count = 0\n            try:\n                for watch_uuid, watch in datastore.data['watching'].items():\n                    if watch.get('tags') and tag_uuid in watch['tags']:\n                        watch['tags'].remove(tag_uuid)\n                        watch.commit()\n                        unlinked_count += 1\n                logger.info(f\"Background: Tag {tag_uuid} unlinked from {unlinked_count} watches\")\n            except Exception as e:\n                logger.error(f\"Error unlinking tag from watches: {e}\")\n\n        # Start daemon thread\n        threading.Thread(target=unlink_tag_background, args=(uuid,), daemon=True).start()\n\n        flash(gettext(\"Unlinking tag from watches in background\"))\n        return redirect(url_for('tags.tags_overview_page'))\n\n    @tags_blueprint.route(\"/delete_all\", methods=['GET'])\n    @login_optionally_required\n    def delete_all():\n\n        for tag_uuid in list(datastore.data['settings']['application']['tags'].keys()):\n# TagsDict 'del' handler will remove the dir\n            del datastore.data['settings']['application']['tags'][tag_uuid]\n\n\n        # Clear tags from all watches in background thread to avoid blocking\n        def clear_all_tags_background():\n            \"\"\"Background thread to clear tags from all watches - discarded after completion.\"\"\"\n            cleared_count = 0\n            try:\n                for watch_uuid, watch in datastore.data['watching'].items():\n                    watch['tags'] = []\n                    watch.commit()\n                    cleared_count += 1\n                logger.info(f\"Background: Cleared tags from {cleared_count} watches\")\n            except Exception as e:\n                logger.error(f\"Error clearing tags from watches: {e}\")\n\n        # Start daemon thread\n        threading.Thread(target=clear_all_tags_background, daemon=True).start()\n\n        flash(gettext(\"All tags deleted, clearing from watches in background\"))\n        return redirect(url_for('tags.tags_overview_page'))\n\n    @tags_blueprint.route(\"/edit/<uuid_str:uuid>\", methods=['GET'])\n    @login_optionally_required\n    def form_tag_edit(uuid):\n        from changedetectionio.blueprint.tags.form import group_restock_settings_form\n        if uuid == 'first':\n            uuid = list(datastore.data['settings']['application']['tags'].keys()).pop()\n\n        default = datastore.data['settings']['application']['tags'].get(uuid)\n        if not default:\n            flash(gettext(\"Tag not found\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        form = group_restock_settings_form(\n                                       formdata=request.form if request.method == 'POST' else None,\n                                       data=default,\n                                       extra_notification_tokens=datastore.get_unique_notification_tokens_available(),\n                                       default_system_settings = datastore.data['settings'],\n                                       )\n\n        # Bridge API-stored processor_config_* values into the form's FormField sub-forms.\n        # The API stores processor_config_restock_diff in the tag dict; find the matching\n        # FormField by checking which one's sub-fields cover the config keys.\n        from wtforms.fields.form import FormField as WTFormField\n        for key, value in default.items():\n            if not key.startswith('processor_config_') or not isinstance(value, dict):\n                continue\n            for form_field in form:\n                if isinstance(form_field, WTFormField) and all(k in form_field.form._fields for k in value):\n                    for sub_key, sub_value in value.items():\n                        sub_field = form_field.form._fields.get(sub_key)\n                        if sub_field is not None:\n                            sub_field.data = sub_value\n                    break\n\n        template_args = {\n            'data': default,\n            'form': form,\n            'watch': default,\n            'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),\n        }\n\n        included_content = {}\n        if form.extra_form_content():\n            # So that the extra panels can access _helpers.html etc, we set the environment to load from templates/\n            # And then render the code from the module\n            from jinja2 import Environment, FileSystemLoader\n            import importlib.resources\n            templates_dir = str(importlib.resources.files(\"changedetectionio\").joinpath('templates'))\n            env = Environment(loader=FileSystemLoader(templates_dir))\n            template_str = \"\"\"{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}\n        <script>        \n            $(document).ready(function () {\n                toggleOpacity('#overrides_watch', '#restock-fieldset-price-group', true);\n            });\n        </script>            \n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        <fieldset class=\"pure-group\">\n                        {{ render_checkbox_field(form.overrides_watch) }}\n                        <span class=\"pure-form-message-inline\">Used for watches in \"Restock & Price detection\" mode</span>\n                        </fieldset>\n                </fieldset>\n                \"\"\"\n            template_str += form.extra_form_content()\n            template = env.from_string(template_str)\n            included_content = template.render(**template_args)\n\n        output = render_template(\"edit-tag.html\",\n                                 extra_form_content=included_content,\n                                 extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None,\n                                 settings_application=datastore.data['settings']['application'],\n                                 **template_args\n                                 )\n\n        return output\n\n\n    @tags_blueprint.route(\"/edit/<uuid_str:uuid>\", methods=['POST'])\n    @login_optionally_required\n    def form_tag_edit_submit(uuid):\n        from changedetectionio.blueprint.tags.form import group_restock_settings_form\n        if uuid == 'first':\n            uuid = list(datastore.data['settings']['application']['tags'].keys()).pop()\n\n        tag = datastore.data['settings']['application']['tags'].get(uuid)\n\n        form = group_restock_settings_form(formdata=request.form if request.method == 'POST' else None,\n                               data=tag,\n                               extra_notification_tokens=datastore.get_unique_notification_tokens_available()\n                               )\n        # @todo subclass form so validation works\n        #if not form.validate():\n#            for widget, l in form.errors.items():\n#                flash(','.join(l), 'error')\n#           return redirect(url_for('tags.form_tag_edit_submit', uuid=uuid))\n\n        tag.update(form.data)\n        tag['processor'] = 'restock_diff'\n        tag.commit()\n\n        # Clear checksums for all watches using this tag to force reprocessing\n        # Tag changes affect inherited configuration\n        cleared_count = datastore.clear_checksums_for_tag(uuid)\n        logger.info(f\"Tag {uuid} updated, cleared {cleared_count} watch checksums\")\n\n        flash(gettext(\"Updated\"))\n\n        return redirect(url_for('tags.tags_overview_page'))\n\n\n    return tags_blueprint\n"
  },
  {
    "path": "changedetectionio/blueprint/tags/form.py",
    "content": "from wtforms import (\n    Form,\n    StringField,\n    SubmitField,\n    validators,\n)\nfrom wtforms.fields.simple import BooleanField\n\nfrom changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form\n\nclass group_restock_settings_form(restock_settings_form):\n    overrides_watch = BooleanField('Activate for individual watches in this tag/group?', default=False)\n\nclass SingleTag(Form):\n\n    name = StringField('Tag name', [validators.InputRequired()], render_kw={\"placeholder\": \"Name\"})\n    save_button = SubmitField('Save', render_kw={\"class\": \"pure-button pure-button-primary\"})\n\n\n\n\n"
  },
  {
    "path": "changedetectionio/blueprint/tags/templates/edit-tag.html",
    "content": "{% extends 'base.html' %}\n{% block content %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_ternary_field %}\n{% from '_common_fields.html' import render_common_settings_form %}\n<script>\n    const notification_base_url=\"{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode=\"group-settings\")}}\";\n</script>\n\n<script src=\"{{url_for('static_content', group='js', filename='tabs.js')}}\" defer></script>\n<script>\n\n/*{% if emailprefix %}*/\n    /*const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}');*/\n/*{% endif %}*/\n\n{% set has_tag_filters_extra='' %}\n\n</script>\n\n<script src=\"{{url_for('static_content', group='js', filename='watch-settings.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='notifications.js')}}\" defer></script>\n\n<div class=\"edit-form monospaced-textarea\">\n\n    <div class=\"tabs collapsable\">\n        <ul>\n            <li class=\"tab\" id=\"\"><a href=\"#general\">{{ _('General') }}</a></li>\n            <li class=\"tab\"><a href=\"#filters-and-triggers\">{{ _('Filters & Triggers') }}</a></li>\n            {% if extra_tab_content %}\n            <li class=\"tab\"><a href=\"#extras_tab\">{{ extra_tab_content }}</a></li>\n            {% endif %}\n            <li class=\"tab\"><a href=\"#notifications\">{{ _('Notifications') }}</a></li>\n        </ul>\n    </div>\n\n    <div class=\"box-wrap inner\">\n        <form class=\"pure-form pure-form-stacked\"\n              action=\"{{ url_for('tags.form_tag_edit', uuid=data.uuid) }}\" method=\"POST\">\n             <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\">\n\n            <div class=\"tab-pane-inner\" id=\"general\">\n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.title, placeholder=\"https://...\", required=true, class=\"m-d\") }}\n                    </div>\n                </fieldset>\n            </div>\n\n            <div class=\"tab-pane-inner\" id=\"filters-and-triggers\">\n                <p>{{ _('These settings are') }} <strong><i>{{ _('added') }}</i></strong> {{ _('to any existing watch configurations.') }}</p>\n                {% include \"edit/include_subtract.html\" %}\n                <div class=\"text-filtering border-fieldset\">\n                    <h3>{{ _('Text filtering') }}</h3>\n                    {% include \"edit/text-options.html\" %}\n                </div>\n            </div>\n\n        {# rendered sub Template #}\n        {% if extra_form_content %}\n            <div class=\"tab-pane-inner\" id=\"extras_tab\">\n            {{ extra_form_content|safe }}\n            </div>\n        {% endif %}\n            <div class=\"tab-pane-inner\" id=\"notifications\">\n                <fieldset>\n                    <div  class=\"pure-control-group inline-radio\">\n                      {{ render_ternary_field(form.notification_muted, BooleanField=True) }}\n                    </div>\n                    {% if 1 %}\n                    <div class=\"pure-control-group inline-radio\">\n                      {{ render_checkbox_field(form.notification_screenshot) }}\n                        <span class=\"pure-form-message-inline\">\n                            <strong>{{ _('Use with caution!') }}</strong> {{ _('This will easily fill up your email storage quota or flood other storages.') }}\n                        </span>\n                    </div>\n                    {% endif %}\n                    <div class=\"field-group\" id=\"notification-field-group\">\n                        {% if has_default_notification_urls %}\n                        <div class=\"inline-warning\">\n                            <img class=\"inline-warning-icon\" src=\"{{url_for('static_content', group='images', filename='notice.svg')}}\" alt=\"{{ _('Look out!') }}\" title=\"{{ _('Lookout!') }}\" >\n                            {{ _('There are') }} <a href=\"{{ url_for('settings.settings_page')}}#notifications\">{{ _('system-wide notification URLs enabled') }}</a>, {{ _('this form will override notification settings for this watch only') }} &dash; {{ _('an empty Notification URL list here will still send notifications.') }}\n                        </div>\n                        {% endif %}\n                        <a href=\"#notifications\" id=\"notification-setting-reset-to-default\" class=\"pure-button button-xsmall\" style=\"right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff\">{{ _('Use system defaults') }}</a>\n\n                        {{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}\n                    </div>\n                </fieldset>\n            </div>\n\n            <div id=\"actions\">\n                <div class=\"pure-control-group\">\n                    {{ render_button(form.save_button) }}\n                </div>\n            </div>\n        </form>\n    </div>\n</div>\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/tags/templates/groups-overview.html",
    "content": "{% extends 'base.html' %}\n{% block content %}\n{% from '_helpers.html' import render_simple_field, render_field %}\n<script src=\"{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}\"></script>\n<script src=\"{{url_for('static_content', group='js', filename='modal.js')}}\"></script>\n\n<div class=\"box\">\n    <form class=\"pure-form\" action=\"{{ url_for('tags.form_tag_add') }}\" method=\"POST\" id=\"new-watch-form\">\n        <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\" >\n        <fieldset>\n            <legend>{{ _('Add a new organisational tag') }}</legend>\n            <div id=\"watch-add-wrapper-zone\">\n                <div>\n                    {{ render_simple_field(form.name, placeholder=_(\"Watch group / tag\")) }}\n                </div>\n                <div>\n                    {{ render_simple_field(form.save_button, title=_(\"Save\") ) }}\n                </div>\n            </div>\n            <br>\n            <div style=\"color: #fff;\">{{ _('Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.') }}</div>\n        </fieldset>\n    </form>\n    <!-- @todo maybe some overview matrix, 'tick' with which has notification, filter rules etc -->\n    <div id=\"watch-table-wrapper\">\n\n        <table class=\"pure-table pure-table-striped watch-table group-overview-table\">\n            <thead>\n            <tr>\n                <th></th>\n                <th>{{ _('# Watches') }}</th>\n                <th>{{ _('Tag / Label name') }}</th>\n                <th></th>\n            </tr>\n            </thead>\n            <tbody>\n            <!--\n            @Todo - connect Last checked, Last Changed, Number of Watches etc\n            --->\n            {% if not available_tags|length %}\n            <tr>\n                <td colspan=\"3\">{{ _('No website organisational tags/groups configured') }}</td>\n            </tr>\n            {% endif %}\n            {% for uuid, tag in available_tags  %}\n            <tr id=\"{{ uuid }}\" class=\"{{ loop.cycle('pure-table-odd', 'pure-table-even') }}\">\n                <td class=\"watch-controls\">\n                    <a class=\"link-mute state-{{'on' if tag.notification_muted else 'off'}}\" href=\"{{url_for('tags.mute', uuid=tag.uuid)}}\"><img src=\"{{url_for('static_content', group='images', filename='bell-off.svg')}}\" alt=\"Mute notifications\" title=\"Mute notifications\" class=\"icon icon-mute\" ></a>\n                </td>\n                <td>{{ \"{:,}\".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>\n                <td class=\"title-col inline\"> <a href=\"{{url_for('watchlist.index', tag=uuid) }}\">{{ tag.title }}</a></td>\n                <td>\n                    <a class=\"pure-button pure-button-primary\" href=\"{{ url_for('tags.form_tag_edit', uuid=uuid) }}\">{{ _('Edit') }}</a>\n                    <a href=\"{{ url_for('ui.form_watch_checknow', tag=uuid) }}\" class=\"pure-button pure-button-primary\" >{{ _('Recheck') }}</a>\n                    <a class=\"pure-button button-error\"\n                       href=\"{{ url_for('tags.delete', uuid=uuid) }}\"\n                       data-requires-confirm\n                       data-confirm-type=\"danger\"\n                       data-confirm-title=\"{{ _('Delete Group?') }}\"\n                       data-confirm-message=\"{{ _('<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>', title=tag.title) }}\"\n                       data-confirm-button=\"{{ _('Delete') }}\"\n                       title=\"{{ _('Deletes and removes tag') }}\">{{ _('Delete') }}</a>\n                    <a class=\"pure-button button-warning\"\n                       href=\"{{ url_for('tags.unlink', uuid=uuid) }}\"\n                       data-requires-confirm\n                       data-confirm-type=\"warning\"\n                       data-confirm-title=\"{{ _('Unlink Group?') }}\"\n                       data-confirm-message=\"{{ _('<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>', title=tag.title) }}\"\n                       data-confirm-button=\"{{ _('Unlink') }}\"\n                       title=\"{{ _('Keep the tag but unlink any watches') }}\">{{ _('Unlink') }}</a>\n                    <a href=\"{{ url_for('rss.rss_tag_feed', tag_uuid=uuid, token=app_rss_token)}}\"><img alt=\"{{ _('RSS Feed for this watch') }}\" style=\"padding-left: 1em;\" src=\"{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}\" height=\"15\"></a>\n                </td>\n            </tr>\n            {% endfor %}\n            </tbody>\n        </table>\n    </div>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/ui/__init__.py",
    "content": "import time\nimport threading\nfrom flask import Blueprint, request, redirect, url_for, flash, render_template, session, current_app\nfrom flask_babel import gettext\nfrom loguru import logger\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.blueprint.ui.edit import construct_blueprint as construct_edit_blueprint\nfrom changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint\nfrom changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint\nfrom changedetectionio.blueprint.ui import diff, preview\n\ndef _handle_operations(op, uuids, datastore, worker_pool, update_q, queuedWatchMetaData, watch_check_update, extra_data=None, emit_flash=True):\n    from flask import request, flash\n\n    if op == 'delete':\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.delete(uuid)\n        if emit_flash:\n            flash(gettext(\"{} watches deleted\").format(len(uuids)))\n\n    elif op == 'pause':\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.data['watching'][uuid]['paused'] = True\n                datastore.data['watching'][uuid].commit()\n        if emit_flash:\n            flash(gettext(\"{} watches paused\").format(len(uuids)))\n\n    elif op == 'unpause':\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.data['watching'][uuid.strip()]['paused'] = False\n                datastore.data['watching'][uuid].commit()\n        if emit_flash:\n            flash(gettext(\"{} watches unpaused\").format(len(uuids)))\n\n    elif (op == 'mark-viewed'):\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.set_last_viewed(uuid, int(time.time()))\n        if emit_flash:\n            flash(gettext(\"{} watches updated\").format(len(uuids)))\n\n    elif (op == 'mute'):\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.data['watching'][uuid]['notification_muted'] = True\n                datastore.data['watching'][uuid].commit()\n        if emit_flash:\n            flash(gettext(\"{} watches muted\").format(len(uuids)))\n\n    elif (op == 'unmute'):\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.data['watching'][uuid]['notification_muted'] = False\n                datastore.data['watching'][uuid].commit()\n        if emit_flash:\n            flash(gettext(\"{} watches un-muted\").format(len(uuids)))\n\n    elif (op == 'recheck'):\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                # Recheck and require a full reprocessing\n                worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n        if emit_flash:\n            flash(gettext(\"{} watches queued for rechecking\").format(len(uuids)))\n\n    elif (op == 'clear-errors'):\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.data['watching'][uuid][\"last_error\"] = False\n                datastore.data['watching'][uuid].commit()\n        if emit_flash:\n            flash(gettext(\"{} watches errors cleared\").format(len(uuids)))\n\n    elif (op == 'clear-history'):\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.clear_watch_history(uuid)\n        if emit_flash:\n            flash(gettext(\"{} watches cleared/reset.\").format(len(uuids)))\n\n    elif (op == 'notification-default'):\n        from changedetectionio.notification import (\n            USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n        )\n        for uuid in uuids:\n            if datastore.data['watching'].get(uuid):\n                datastore.data['watching'][uuid]['notification_title'] = None\n                datastore.data['watching'][uuid]['notification_body'] = None\n                datastore.data['watching'][uuid]['notification_urls'] = []\n                datastore.data['watching'][uuid]['notification_format'] = USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n                datastore.data['watching'][uuid].commit()\n        if emit_flash:\n            flash(gettext(\"{} watches set to use default notification settings\").format(len(uuids)))\n\n    elif (op == 'assign-tag'):\n        op_extradata = extra_data\n        if op_extradata:\n            tag_uuid = datastore.add_tag(title=op_extradata)\n            if op_extradata and tag_uuid:\n                for uuid in uuids:\n                    if datastore.data['watching'].get(uuid):\n                        # Bug in old versions caused by bad edit page/tag handler\n                        if isinstance(datastore.data['watching'][uuid]['tags'], str):\n                            datastore.data['watching'][uuid]['tags'] = []\n\n                        datastore.data['watching'][uuid]['tags'].append(tag_uuid)\n                        datastore.data['watching'][uuid].commit()\n        if emit_flash:\n            flash(gettext(\"{} watches were tagged\").format(len(uuids)))\n\n    if uuids:\n        for uuid in uuids:\n            watch_check_update.send(watch_uuid=uuid)\n\ndef construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, queuedWatchMetaData, watch_check_update):\n    ui_blueprint = Blueprint('ui', __name__, template_folder=\"templates\")\n    \n    # Register the edit blueprint\n    edit_blueprint = construct_edit_blueprint(datastore, update_q, queuedWatchMetaData)\n    ui_blueprint.register_blueprint(edit_blueprint)\n    \n    # Register the notification blueprint\n    notification_blueprint = construct_notification_blueprint(datastore)\n    ui_blueprint.register_blueprint(notification_blueprint)\n    \n    # Register the views blueprint\n    views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData, watch_check_update)\n    ui_blueprint.register_blueprint(views_blueprint)\n\n    # Register diff and preview blueprints\n    diff_blueprint = diff.construct_blueprint(datastore)\n    ui_blueprint.register_blueprint(diff_blueprint)\n\n    preview_blueprint = preview.construct_blueprint(datastore)\n    ui_blueprint.register_blueprint(preview_blueprint)\n\n    # Import the login decorator\n    from changedetectionio.auth_decorator import login_optionally_required\n\n    @ui_blueprint.route(\"/clear_history/<uuid_str:uuid>\", methods=['GET'])\n    @login_optionally_required\n    def clear_watch_history(uuid):\n        try:\n            datastore.clear_watch_history(uuid)\n        except KeyError:\n            flash(gettext('Watch not found'), 'error')\n        else:\n            flash(gettext(\"Cleared snapshot history for watch {}\").format(uuid))\n        return redirect(url_for('watchlist.index'))\n\n    @ui_blueprint.route(\"/clear_history\", methods=['GET', 'POST'])\n    @login_optionally_required\n    def clear_all_history():\n        if request.method == 'POST':\n            confirmtext = request.form.get('confirmtext', '')\n\n            if confirmtext.strip().lower() == gettext('clear').strip().lower():\n                # Run in background thread to avoid blocking\n                def clear_history_background():\n                    # Capture UUIDs first to avoid race conditions\n                    watch_uuids = list(datastore.data['watching'].keys())\n                    logger.info(f\"Background: Clearing history for {len(watch_uuids)} watches\")\n\n                    for uuid in watch_uuids:\n                        try:\n                            datastore.clear_watch_history(uuid)\n                        except Exception as e:\n                            logger.error(f\"Error clearing history for watch {uuid}: {e}\")\n\n                    logger.info(\"Background: Completed clearing history\")\n\n                # Start daemon thread\n                threading.Thread(target=clear_history_background, daemon=True).start()\n\n                flash(gettext(\"History clearing started in background\"))\n            else:\n                flash(gettext('Incorrect confirmation text.'), 'error')\n\n            return redirect(url_for('watchlist.index'))\n\n        output = render_template(\"clear_all_history.html\")\n        return output\n\n    # Clear all statuses, so we do not see the 'unviewed' class\n    @ui_blueprint.route(\"/form/mark-all-viewed\", methods=['GET'])\n    @login_optionally_required\n    def mark_all_viewed():\n        # Save the current newest history as the most recently viewed\n        with_errors = request.args.get('with_errors') == \"1\"\n        tag_limit = request.args.get('tag')\n        now = int(time.time())\n\n        # Mark watches as viewed - use background thread only for large watch counts\n        def mark_viewed_impl():\n            \"\"\"Mark watches as viewed - can run synchronously or in background thread.\"\"\"\n            marked_count = 0\n            try:\n                for watch_uuid, watch in datastore.data['watching'].items():\n                    if with_errors and not watch.get('last_error'):\n                        continue\n\n                    if tag_limit and (not watch.get('tags') or tag_limit not in watch['tags']):\n                        continue\n\n                    datastore.set_last_viewed(watch_uuid, now)\n                    marked_count += 1\n\n                logger.info(f\"Marking complete: {marked_count} watches marked as viewed\")\n            except Exception as e:\n                logger.error(f\"Error marking as viewed: {e}\")\n\n        # For small watch counts (< 10), run synchronously to avoid race conditions in tests\n        # For larger counts, use background thread to avoid blocking the UI\n        watch_count = len(datastore.data['watching'])\n        if watch_count < 10:\n            # Run synchronously for small watch counts\n            mark_viewed_impl()\n        else:\n            # Start background thread for large watch counts\n            thread = threading.Thread(target=mark_viewed_impl, daemon=True)\n            thread.start()\n\n        return redirect(url_for('watchlist.index', tag=tag_limit))\n\n    @ui_blueprint.route(\"/delete\", methods=['GET'])\n    @login_optionally_required\n    def form_delete():\n        uuid = request.args.get('uuid')\n        # More for testing, possible to return the first/only\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n\n        if uuid != 'all' and not uuid in datastore.data['watching'].keys():\n            flash(gettext('The watch by UUID {} does not exist.').format(uuid), 'error')\n            return redirect(url_for('watchlist.index'))\n\n        datastore.delete(uuid)\n        flash(gettext('Deleted.'))\n\n        return redirect(url_for('watchlist.index'))\n\n    @ui_blueprint.route(\"/clone\", methods=['GET'])\n    @login_optionally_required\n    def form_clone():\n        uuid = request.args.get('uuid')\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n\n        new_uuid = datastore.clone(uuid)\n\n        if not datastore.data['watching'].get(uuid).get('paused'):\n            worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))\n\n        flash(gettext('Cloned, you are editing the new watch.'))\n\n        return redirect(url_for(\"ui.ui_edit.edit_page\", uuid=new_uuid))\n\n    @ui_blueprint.route(\"/checknow\", methods=['GET'])\n    @login_optionally_required\n    def form_watch_checknow():\n        # Forced recheck will skip the 'skip if content is the same' rule (, 'reprocess_existing_data': True})))\n        tag = request.args.get('tag')\n        uuid = request.args.get('uuid')\n        with_errors = request.args.get('with_errors') == \"1\"\n\n        if uuid:\n            # Single watch - check if already queued or running\n            if worker_pool.is_watch_running(uuid) or uuid in update_q.get_queued_uuids():\n                flash(gettext(\"Watch is already queued or being checked.\"))\n            else:\n                worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n                flash(gettext(\"Queued 1 watch for rechecking.\"))\n        else:\n            # Multiple watches - first count how many need to be queued\n            watches_to_queue = []\n            for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):\n                watch_uuid = k[0]\n                watch = k[1]\n                if not watch['paused'] and watch_uuid:\n                    if with_errors and not watch.get('last_error'):\n                        continue\n                    if tag != None and tag not in watch['tags']:\n                        continue\n                    watches_to_queue.append(watch_uuid)\n\n            # If less than 20 watches, queue synchronously for immediate feedback\n            if len(watches_to_queue) < 20:\n                # Get already queued/running UUIDs once (efficient)\n                queued_uuids = set(update_q.get_queued_uuids())\n                running_uuids = set(worker_pool.get_running_uuids())\n\n                # Filter out watches that are already queued or running\n                watches_to_queue_filtered = []\n                for watch_uuid in watches_to_queue:\n                    if watch_uuid not in queued_uuids and watch_uuid not in running_uuids:\n                        watches_to_queue_filtered.append(watch_uuid)\n\n                # Queue only the filtered watches\n                for watch_uuid in watches_to_queue_filtered:\n                    worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))\n\n                # Provide feedback about skipped watches\n                skipped_count = len(watches_to_queue) - len(watches_to_queue_filtered)\n                if skipped_count > 0:\n                    flash(gettext(\"Queued {} watches for rechecking ({} already queued or running).\").format(\n                        len(watches_to_queue_filtered), skipped_count))\n                else:\n                    if len(watches_to_queue_filtered) == 1:\n                        flash(gettext(\"Queued 1 watch for rechecking.\"))\n                    else:\n                        flash(gettext(\"Queued {} watches for rechecking.\").format(len(watches_to_queue_filtered)))\n            else:\n                # 20+ watches - queue in background thread to avoid blocking HTTP response\n                # Capture queued/running state before background thread\n                queued_uuids = set(update_q.get_queued_uuids())\n                running_uuids = set(worker_pool.get_running_uuids())\n\n                def queue_watches_background():\n                    \"\"\"Background thread to queue watches - discarded after completion.\"\"\"\n                    try:\n                        queued_count = 0\n                        skipped_count = 0\n                        for watch_uuid in watches_to_queue:\n                            # Check if already queued or running (state captured at start)\n                            if watch_uuid not in queued_uuids and watch_uuid not in running_uuids:\n                                worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))\n                                queued_count += 1\n                            else:\n                                skipped_count += 1\n\n                        logger.info(f\"Background queueing complete: {queued_count} watches queued, {skipped_count} skipped (already queued/running)\")\n                    except Exception as e:\n                        logger.error(f\"Error in background queueing: {e}\")\n\n                # Start background thread and return immediately\n                thread = threading.Thread(target=queue_watches_background, daemon=True, name=\"QueueWatches-Background\")\n                thread.start()\n\n                # Return immediately with approximate message\n                flash(gettext(\"Queueing watches for rechecking in background...\"))\n\n        return redirect(url_for('watchlist.index', **({'tag': tag} if tag else {})))\n\n    @ui_blueprint.route(\"/form/checkbox-operations\", methods=['POST'])\n    @login_optionally_required\n    def form_watch_list_checkbox_operations():\n        op = request.form['op']\n        uuids = [u.strip() for u in request.form.getlist('uuids') if u]\n        extra_data = request.form.get('op_extradata', '').strip()\n        _handle_operations(\n            datastore=datastore,\n            extra_data=extra_data,\n            queuedWatchMetaData=queuedWatchMetaData,\n            uuids=uuids,\n            worker_pool=worker_pool,\n            update_q=update_q,\n            watch_check_update=watch_check_update,\n            op=op,\n        )\n\n        return redirect(url_for('watchlist.index'))\n\n\n    @ui_blueprint.route(\"/share-url/<uuid_str:uuid>\", methods=['GET'])\n    @login_optionally_required\n    def form_share_put_watch(uuid):\n        \"\"\"Given a watch UUID, upload the info and return a share-link\n           the share-link can be imported/added\"\"\"\n        import requests\n        import json\n        from copy import deepcopy\n\n\n        # copy it to memory as trim off what we dont need (history)\n        watch = deepcopy(datastore.data['watching'].get(uuid))\n        # For older versions that are not a @property\n        if (watch.get('history')):\n            del (watch['history'])\n\n        # for safety/privacy\n        for k in list(watch.keys()):\n            if k.startswith('notification_'):\n                del watch[k]\n\n        for r in['uuid', 'last_checked', 'last_changed']:\n            if watch.get(r):\n                del (watch[r])\n\n        # Add the global stuff which may have an impact\n        watch['ignore_text'] += datastore.data['settings']['application']['global_ignore_text']\n        watch['subtractive_selectors'] += datastore.data['settings']['application']['global_subtractive_selectors']\n\n        watch_json = json.dumps(watch)\n\n        try:\n            r = requests.request(method=\"POST\",\n                                 data={'watch': watch_json},\n                                 url=\"https://changedetection.io/share/share\",\n                                 headers={'App-Guid': datastore.data['app_guid']})\n            res = r.json()\n\n            # Add to the flask session\n            session['share-link'] = f\"https://changedetection.io/share/{res['share_key']}\"\n\n\n        except Exception as e:\n            logger.error(f\"Error sharing -{str(e)}\")\n            flash(gettext(\"Could not share, something went wrong while communicating with the share server - {}\").format(str(e)), 'error')\n\n        return redirect(url_for('watchlist.index'))\n\n    @ui_blueprint.route(\"/language/auto-detect\", methods=['GET'])\n    def delete_locale_language_session_var_if_it_exists():\n        \"\"\"Clear the session locale preference to auto-detect from browser Accept-Language header\"\"\"\n        if 'locale' in session:\n            session.pop('locale', None)\n            # Refresh Flask-Babel to clear cached locale\n            from flask_babel import refresh\n            refresh()\n            flash(gettext(\"Language set to auto-detect from browser\"))\n\n        # Check if there's a redirect parameter to return to the same page\n        redirect_url = request.args.get('redirect')\n\n        # If redirect is provided and safe, use it\n        from changedetectionio.is_safe_url import is_safe_url\n        if redirect_url and is_safe_url(redirect_url, current_app):\n            return redirect(redirect_url)\n\n        # Otherwise redirect to watchlist\n        return redirect(url_for('watchlist.index'))\n\n    return ui_blueprint"
  },
  {
    "path": "changedetectionio/blueprint/ui/diff.py",
    "content": "from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory\nfrom flask_babel import gettext\n\nimport re\nimport importlib\nfrom loguru import logger\nfrom markupsafe import Markup\n\nfrom changedetectionio.diff import (\n    REMOVED_STYLE, ADDED_STYLE, REMOVED_INNER_STYLE, ADDED_INNER_STYLE,\n    REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED,\n    ADDED_PLACEMARKER_OPEN, ADDED_PLACEMARKER_CLOSED,\n    CHANGED_PLACEMARKER_OPEN, CHANGED_PLACEMARKER_CLOSED,\n    CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED\n)\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.auth_decorator import login_optionally_required\n\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    diff_blueprint = Blueprint('ui_diff', __name__, template_folder=\"../ui/templates\")\n\n    @diff_blueprint.app_template_filter('diff_unescape_difference_spans')\n    def diff_unescape_difference_spans(content):\n        \"\"\"Emulate Jinja2's auto-escape, then selectively unescape our diff spans.\"\"\"\n        from markupsafe import escape\n\n        if not content:\n            return Markup('')\n\n        # Step 1: Escape everything like Jinja2 would (this makes it XSS-safe)\n        escaped_content = escape(str(content))\n\n        # Step 2: Unescape only our exact diff spans generated by apply_html_color_to_body()\n        # Pattern matches the exact structure:\n        # <span style=\"{STYLE}\" role=\"{ROLE}\" aria-label=\"{LABEL}\" title=\"{TITLE}\">\n\n        # Unescape outer span opening tags with full attributes (role, aria-label, title)\n        # Matches removed/added/changed/changed_into spans\n        result = re.sub(\n            rf'&lt;span style=&#34;({re.escape(REMOVED_STYLE)}|{re.escape(ADDED_STYLE)})&#34; '\n            rf'role=&#34;(deletion|insertion|note)&#34; '\n            rf'aria-label=&#34;([^&]+?)&#34; '\n            rf'title=&#34;([^&]+?)&#34;&gt;',\n            r'<span style=\"\\1\" role=\"\\2\" aria-label=\"\\3\" title=\"\\4\">',\n            str(escaped_content),\n            flags=re.IGNORECASE\n        )\n\n        # Unescape inner span opening tags (without additional attributes)\n        # This matches the darker background styles for changed parts within lines\n        result = re.sub(\n            rf'&lt;span style=&#34;({re.escape(REMOVED_INNER_STYLE)}|{re.escape(ADDED_INNER_STYLE)})&#34;&gt;',\n            r'<span style=\"\\1\">',\n            result,\n            flags=re.IGNORECASE\n        )\n\n        # Unescape closing tags (but only as many as we opened)\n        open_count = result.count('<span style=')\n        close_count = str(escaped_content).count('&lt;/span&gt;')\n\n        # Replace up to the number of spans we opened\n        for _ in range(min(open_count, close_count)):\n            result = result.replace('&lt;/span&gt;', '</span>', 1)\n\n        return Markup(result)\n\n    @diff_blueprint.route(\"/diff/<uuid_str:uuid>\", methods=['GET'])\n    @login_optionally_required\n    def diff_history_page(uuid):\n        \"\"\"\n        Render the history/diff page for a watch.\n\n        This route is processor-aware: it delegates rendering to the processor's\n        difference.py module, allowing different processor types to provide\n        custom visualizations:\n        - text_json_diff: Text/HTML diff with syntax highlighting\n        - restock_diff: Could show price charts and stock history\n        - image_diff: Could show image comparison slider/overlay\n\n        Each processor implements processors/{type}/difference.py::render()\n        If a processor doesn't have a difference module, falls back to text_json_diff.\n        \"\"\"\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n\n        try:\n            watch = datastore.data['watching'][uuid]\n        except KeyError:\n            flash(gettext(\"No history found for the specified link, bad link?\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        dates = list(watch.history.keys())\n        if not dates or len(dates) < 2:\n            flash(gettext(\"Not enough history (2 snapshots required) to show difference page for this watch.\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        # Get the processor type for this watch\n        processor_name = watch.get('processor', 'text_json_diff')\n\n        # Try to get the processor's difference module (works for both built-in and plugin processors)\n        from changedetectionio.processors import get_processor_submodule\n        processor_module = get_processor_submodule(processor_name, 'difference')\n\n        # Call the processor's render() function\n        if processor_module and hasattr(processor_module, 'render'):\n            return processor_module.render(\n                watch=watch,\n                datastore=datastore,\n                request=request,\n                url_for=url_for,\n                render_template=render_template,\n                flash=flash,\n                redirect=redirect\n            )\n\n        # Fallback: if processor doesn't have difference module, use text_json_diff as default\n        from changedetectionio.processors.text_json_diff.difference import render as default_render\n        return default_render(\n            watch=watch,\n            datastore=datastore,\n            request=request,\n            url_for=url_for,\n            render_template=render_template,\n            flash=flash,\n            redirect=redirect\n        )\n\n    @diff_blueprint.route(\"/diff/<uuid_str:uuid>/extract\", methods=['GET'])\n    @login_optionally_required\n    def diff_history_page_extract_GET(uuid):\n        \"\"\"\n        Render the data extraction form for a watch.\n\n        This route is processor-aware: it delegates to the processor's\n        extract.py module, allowing different processor types to provide\n        custom extraction interfaces.\n\n        Each processor implements processors/{type}/extract.py::render_form()\n        If a processor doesn't have an extract module, falls back to text_json_diff.\n        \"\"\"\n\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n        try:\n            watch = datastore.data['watching'][uuid]\n        except KeyError:\n            flash(gettext(\"No history found for the specified link, bad link?\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        # Get the processor type for this watch\n        processor_name = watch.get('processor', 'text_json_diff')\n\n        # Try to get the processor's extract module (works for both built-in and plugin processors)\n        from changedetectionio.processors import get_processor_submodule\n        processor_module = get_processor_submodule(processor_name, 'extract')\n\n        # Call the processor's render_form() function\n        if processor_module and hasattr(processor_module, 'render_form'):\n            return processor_module.render_form(\n                watch=watch,\n                datastore=datastore,\n                request=request,\n                url_for=url_for,\n                render_template=render_template,\n                flash=flash,\n                redirect=redirect\n            )\n\n        # Fallback: if processor doesn't have extract module, use base processors.extract as default\n        from changedetectionio.processors.extract import render_form as default_render_form\n        return default_render_form(\n            watch=watch,\n            datastore=datastore,\n            request=request,\n            url_for=url_for,\n            render_template=render_template,\n            flash=flash,\n            redirect=redirect\n        )\n\n    @diff_blueprint.route(\"/diff/<uuid_str:uuid>/extract\", methods=['POST'])\n    @login_optionally_required\n    def diff_history_page_extract_POST(uuid):\n        \"\"\"\n        Process the data extraction request.\n\n        This route is processor-aware: it delegates to the processor's\n        extract.py module, allowing different processor types to provide\n        custom extraction logic.\n\n        Each processor implements processors/{type}/extract.py::process_extraction()\n        If a processor doesn't have an extract module, falls back to text_json_diff.\n        \"\"\"\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n\n        try:\n            watch = datastore.data['watching'][uuid]\n        except KeyError:\n            flash(gettext(\"No history found for the specified link, bad link?\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        # Get the processor type for this watch\n        processor_name = watch.get('processor', 'text_json_diff')\n\n        # Try to get the processor's extract module (works for both built-in and plugin processors)\n        from changedetectionio.processors import get_processor_submodule\n        processor_module = get_processor_submodule(processor_name, 'extract')\n\n        # Call the processor's process_extraction() function\n        if processor_module and hasattr(processor_module, 'process_extraction'):\n            return processor_module.process_extraction(\n                watch=watch,\n                datastore=datastore,\n                request=request,\n                url_for=url_for,\n                make_response=make_response,\n                send_from_directory=send_from_directory,\n                flash=flash,\n                redirect=redirect\n            )\n\n        # Fallback: if processor doesn't have extract module, use base processors.extract as default\n        from changedetectionio.processors.extract import process_extraction as default_process_extraction\n        return default_process_extraction(\n            watch=watch,\n            datastore=datastore,\n            request=request,\n            url_for=url_for,\n            make_response=make_response,\n            send_from_directory=send_from_directory,\n            flash=flash,\n            redirect=redirect\n        )\n\n    @diff_blueprint.route(\"/diff/<uuid_str:uuid>/processor-asset/<string:asset_name>\", methods=['GET'])\n    @login_optionally_required\n    def processor_asset(uuid, asset_name):\n        \"\"\"\n        Serve processor-specific binary assets (images, files, etc.).\n\n        This route is processor-aware: it delegates to the processor's\n        difference.py module, allowing different processor types to serve\n        custom assets without embedding them as base64 in templates.\n\n        This solves memory issues with large binary data (e.g., screenshots)\n        by streaming them as separate HTTP responses instead of embedding\n        in the HTML template.\n\n        Each processor implements processors/{type}/difference.py::get_asset()\n        which returns (binary_data, content_type, cache_control_header).\n\n        Example URLs:\n        - /diff/{uuid}/processor-asset/before\n        - /diff/{uuid}/processor-asset/after\n        - /diff/{uuid}/processor-asset/rendered_diff\n        \"\"\"\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n\n        try:\n            watch = datastore.data['watching'][uuid]\n        except KeyError:\n            flash(gettext(\"No history found for the specified link, bad link?\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        # Get the processor type for this watch\n        processor_name = watch.get('processor', 'text_json_diff')\n\n        # Try to get the processor's difference module (works for both built-in and plugin processors)\n        from changedetectionio.processors import get_processor_submodule\n        processor_module = get_processor_submodule(processor_name, 'difference')\n\n        # Call the processor's get_asset() function\n        if processor_module and hasattr(processor_module, 'get_asset'):\n            result = processor_module.get_asset(\n                asset_name=asset_name,\n                watch=watch,\n                datastore=datastore,\n                request=request\n            )\n\n            if result is None:\n                from flask import abort\n                abort(404, description=f\"Asset '{asset_name}' not found\")\n\n            binary_data, content_type, cache_control = result\n\n            response = make_response(binary_data)\n            response.headers['Content-Type'] = content_type\n            if cache_control:\n                response.headers['Cache-Control'] = cache_control\n            return response\n        else:\n            logger.warning(f\"Processor {processor_name} does not implement get_asset()\")\n            from flask import abort\n            abort(404, description=f\"Processor '{processor_name}' does not support assets\")\n\n    return diff_blueprint\n"
  },
  {
    "path": "changedetectionio/blueprint/ui/edit.py",
    "content": "from copy import deepcopy\nimport os\nimport importlib.resources\nfrom flask import Blueprint, request, redirect, url_for, flash, render_template, abort\nfrom flask_babel import gettext\nfrom loguru import logger\nfrom jinja2 import Environment, FileSystemLoader\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.auth_decorator import login_optionally_required\nfrom changedetectionio.time_handler import is_within_schedule\nfrom changedetectionio import worker_pool\n\ndef construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):\n    edit_blueprint = Blueprint('ui_edit', __name__, template_folder=\"../ui/templates\")\n    \n    def _watch_has_tag_options_set(watch):\n        \"\"\"This should be fixed better so that Tag is some proper Model, a tag is just a Watch also\"\"\"\n        for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():\n            if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')):\n                return True\n\n    @edit_blueprint.route(\"/edit/<uuid_str:uuid>\", methods=['GET', 'POST'])\n    @login_optionally_required\n    # https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists\n    # https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ?\n    def edit_page(uuid):\n        from changedetectionio import forms\n        from changedetectionio.browser_steps.browser_steps import browser_step_ui_config\n        from changedetectionio import processors\n        import importlib\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n        # More for testing, possible to return the first/only\n        if not datastore.data['watching'].keys():\n            flash(gettext(\"No watches to edit\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        if not uuid in datastore.data['watching']:\n            flash(gettext(\"No watch with the UUID {} found.\").format(uuid), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        switch_processor = request.args.get('switch_processor')\n        if switch_processor:\n            for p in processors.available_processors():\n                if p[0] == switch_processor:\n                    datastore.data['watching'][uuid]['processor'] = switch_processor\n                    flash(gettext(\"Switched to mode - {}.\").format(p[1]))\n                    datastore.clear_watch_history(uuid)\n                    redirect(url_for('ui_edit.edit_page', uuid=uuid))\n\n        # be sure we update with a copy instead of accidently editing the live object by reference\n        default = None\n        while not default:\n            try:\n                default = deepcopy(datastore.data['watching'][uuid])\n            except RuntimeError as e:\n                # Dictionary changed\n                continue\n\n        # Defaults for proxy choice\n        if datastore.proxy_list is not None:  # When enabled\n            # @todo\n            # Radio needs '' not None, or incase that the chosen one no longer exists\n            if default['proxy'] is None or not any(default['proxy'] in tup for tup in datastore.proxy_list):\n                default['proxy'] = ''\n        # proxy_override set to the json/text list of the items\n\n        # Does it use some custom form? does one exist?\n        processor_name = datastore.data['watching'][uuid].get('processor', '')\n        processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None)\n        if not processor_classes:\n            flash(gettext(\"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\").format(processor_name), 'error')\n            # Fall back to default processor so user can still edit and change processor\n            processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == 'text_json_diff'), None)\n            if not processor_classes:\n                # If even text_json_diff is missing, something is very wrong\n                flash(gettext(\"Could not load '{}' processor, processor plugin might be missing.\").format(processor_name), 'error')\n                return redirect(url_for('watchlist.index'))\n\n        parent_module = processors.get_parent_module(processor_classes[0])\n\n        try:\n            # Get the parent of the \"processor.py\" go up one, get the form (kinda spaghetti but its reusing existing code)\n            forms_module = importlib.import_module(f\"{parent_module.__name__}.forms\")\n            # Access the 'processor_settings_form' class from the 'forms' module\n            form_class = getattr(forms_module, 'processor_settings_form')\n        except ModuleNotFoundError as e:\n            # .forms didnt exist\n            form_class = forms.processor_text_json_diff_form\n        except AttributeError as e:\n            # .forms exists but no useful form\n            form_class = forms.processor_text_json_diff_form\n\n        form = form_class(formdata=request.form if request.method == 'POST' else None,\n                          data=default,\n                          extra_notification_tokens=default.extra_notification_token_values(),\n                          default_system_settings=datastore.data['settings']\n                          )\n\n        # For the form widget tag UUID back to \"string name\" for the field\n        form.tags.datastore = datastore\n\n        # Used by some forms that need to dig deeper\n        form.datastore = datastore\n        form.watch = default\n\n        # Load processor-specific config from JSON file for GET requests\n        if request.method == 'GET' and processor_name:\n            try:\n                from changedetectionio.processors.base import difference_detection_processor\n                # Create a processor instance to access config methods\n                processor_instance = difference_detection_processor(datastore, uuid)\n                # Use processor name as filename so each processor keeps its own config\n                config_filename = f'{processor_name}.json'\n                processor_config = processor_instance.get_extra_watch_config(config_filename)\n\n                if processor_config:\n                    from wtforms.fields.form import FormField\n                    # Populate processor-config-* fields from JSON\n                    for config_key, config_value in processor_config.items():\n                        if not isinstance(config_value, dict):\n                            continue\n                        # Try exact API-named field first (e.g., processor_config_restock_diff)\n                        target_field = getattr(form, f'processor_config_{config_key}', None)\n                        # Fallback: find any FormField sub-form whose fields cover config_value keys\n                        if target_field is None:\n                            for form_field in form:\n                                if isinstance(form_field, FormField) and all(k in form_field.form._fields for k in config_value):\n                                    target_field = form_field\n                                    break\n                        if target_field is not None:\n                            for sub_key, sub_value in config_value.items():\n                                sub_field = target_field.form._fields.get(sub_key)\n                                if sub_field is not None:\n                                    sub_field.data = sub_value\n                                    logger.debug(f\"Loaded processor config from {config_filename}: {sub_key} = {sub_value}\")\n            except Exception as e:\n                logger.warning(f\"Failed to load processor config: {e}\")\n\n        for p in datastore.extra_browsers:\n            form.fetch_backend.choices.append(p)\n\n        form.fetch_backend.choices.append((\"system\", 'System settings default'))\n\n        # form.browser_steps[0] can be assumed that we 'goto url' first\n\n        if datastore.proxy_list is None:\n            # @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead\n            del form.proxy\n        else:\n            form.proxy.choices = [('', 'Default')]\n            for p in datastore.proxy_list:\n                form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label'])))\n\n\n        if request.method == 'POST' and form.validate():\n\n            extra_update_obj = {\n                'consecutive_filter_failures': 0,\n                'last_error' : False\n            }\n\n            if request.args.get('unpause_on_save'):\n                extra_update_obj['paused'] = False\n\n            extra_update_obj['time_between_check'] = form.time_between_check.data\n\n            # Handle processor-config-* fields separately (save to JSON, not datastore)\n            # IMPORTANT: These must NOT be saved to url-watches.json, only to the processor-specific JSON file\n            processor_config_data = processors.extract_processor_config_from_form_data(form.data)\n            processors.save_processor_config(datastore, uuid, processor_config_data)\n\n            # Ignore text\n            form_ignore_text = form.ignore_text.data\n            datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text\n\n            # Be sure proxy value is None\n            if datastore.proxy_list is not None and form.data['proxy'] == '':\n                extra_update_obj['proxy'] = None\n\n            # Unsetting all filter_text methods should make it go back to default\n            # This particularly affects tests running\n            if 'filter_text_added' in form.data and not form.data.get('filter_text_added') \\\n                    and 'filter_text_replaced' in form.data and not form.data.get('filter_text_replaced') \\\n                    and 'filter_text_removed' in form.data and not form.data.get('filter_text_removed'):\n                extra_update_obj['filter_text_added'] = True\n                extra_update_obj['filter_text_replaced'] = True\n                extra_update_obj['filter_text_removed'] = True\n\n            # Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs\n            tag_uuids = []\n            if form.data.get('tags'):\n                # Sometimes in testing this can be list, dont know why\n                if type(form.data.get('tags')) == list:\n                    extra_update_obj['tags'] = form.data.get('tags')\n                else:\n                    for t in form.data.get('tags').split(','):\n                        tag_uuids.append(datastore.add_tag(title=t))\n                    extra_update_obj['tags'] = tag_uuids\n\n            datastore.data['watching'][uuid].update(form.data)\n            datastore.data['watching'][uuid].update(extra_update_obj)\n\n            if not datastore.data['watching'][uuid].get('tags'):\n                # Force it to be a list, because form.data['tags'] will be string if nothing found\n                # And del(form.data['tags'] ) wont work either for some reason\n                datastore.data['watching'][uuid]['tags'] = []\n\n            # Recast it if need be to right data Watch handler\n            watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor'))\n            datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, __datastore=datastore.data, default=datastore.data['watching'][uuid])\n\n            # Save the watch immediately\n            datastore.data['watching'][uuid].commit()\n\n            flash(gettext(\"Updated watch - unpaused!\") if request.args.get('unpause_on_save') else gettext(\"Updated watch.\"))\n\n            # Cleanup any browsersteps session for this watch\n            try:\n                from changedetectionio.blueprint.browser_steps import cleanup_session_for_watch\n                cleanup_session_for_watch(uuid)\n            except Exception as e:\n                logger.debug(f\"Error cleaning up browsersteps session: {e}\")\n\n            # Do not queue on edit if its not within the time range\n\n            # @todo maybe it should never queue anyway on edit...\n            is_in_schedule = True\n            watch = datastore.data['watching'].get(uuid)\n\n            if watch.get('time_between_check_use_default'):\n                time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})\n            else:\n                time_schedule_limit = watch.get('time_schedule_limit')\n\n            tz_name = time_schedule_limit.get('timezone')\n            if not tz_name:\n                tz_name = datastore.data['settings']['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC').strip())\n\n            if time_schedule_limit and time_schedule_limit.get('enabled'):\n                try:\n                    is_in_schedule = is_within_schedule(time_schedule_limit=time_schedule_limit,\n                                                      default_tz=tz_name\n                                                      )\n                except Exception as e:\n                    logger.error(\n                        f\"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}\")\n                    return False\n\n            #############################\n            if not datastore.data['watching'][uuid].get('paused') and is_in_schedule:\n                # Queue the watch for immediate recheck, with a higher priority\n                worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n\n            # Diff page [edit] link should go back to diff page\n            if request.args.get(\"next\") and request.args.get(\"next\") == 'diff':\n                return redirect(url_for('ui.ui_diff.diff_history_page', uuid=uuid))\n\n            return redirect(url_for('watchlist.index', tag=request.args.get(\"tag\",'')))\n\n        else:\n            if request.method == 'POST' and not form.validate():\n                flash(gettext(\"An error occurred, please see below.\"), \"error\")\n\n            # JQ is difficult to install on windows and must be manually added (outside requirements.txt)\n            jq_support = True\n            try:\n                import jq\n            except ModuleNotFoundError:\n                jq_support = False\n\n            watch = datastore.data['watching'].get(uuid)\n\n            from zoneinfo import available_timezones\n\n            # Import the global plugin system\n            from changedetectionio.pluggy_interface import collect_ui_edit_stats_extras, get_fetcher_capabilities\n\n            # Get fetcher capabilities instead of hardcoded logic\n            capabilities = get_fetcher_capabilities(watch, datastore)\n\n            # Add processor capabilities from module\n            capabilities['supports_visual_selector'] = getattr(parent_module, 'supports_visual_selector', False)\n            capabilities['supports_text_filters_and_triggers'] = getattr(parent_module, 'supports_text_filters_and_triggers', False)\n            capabilities['supports_text_filters_and_triggers_elements'] = getattr(parent_module, 'supports_text_filters_and_triggers_elements', False)\n            capabilities['supports_request_type'] = getattr(parent_module, 'supports_request_type', False)\n\n            app_rss_token = datastore.data['settings']['application'].get('rss_access_token'),\n\n            c = [f\"processor-{watch.get('processor')}\"]\n            if worker_pool.is_watch_running(uuid):\n                c.append('checking-now')\n\n            template_args = {\n                'available_processors': processors.available_processors(),\n                'available_timezones': sorted(available_timezones()),\n                'browser_steps_config': browser_step_ui_config,\n                'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),\n                'extra_classes': ' '.join(c),\n                'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),\n                'extra_processor_config': form.extra_tab_content(),\n                'extra_title': f\" - Edit - {watch.label}\",\n                'form': form,\n                'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False,\n                'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,\n                'has_special_tag_options': _watch_has_tag_options_set(watch=watch),\n                'jq_support': jq_support,\n                'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False),\n                'app_rss_token': app_rss_token,\n                'rss_uuid_feed' : {\n                    'label': watch.label,\n                    'url': url_for('rss.rss_single_watch', uuid=watch['uuid'], token=app_rss_token)\n                },\n                'settings_application': datastore.data['settings']['application'],\n                'ui_edit_stats_extras': collect_ui_edit_stats_extras(watch),\n                'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid),\n                'timezone_default_config': datastore.data['settings']['application'].get('scheduler_timezone_default'),\n                'using_global_webdriver_wait': not default['webdriver_delay'],\n                'uuid': uuid,\n                'watch': watch,\n                'capabilities': capabilities\n            }\n\n            included_content = None\n            if form.extra_form_content():\n                # So that the extra panels can access _helpers.html etc, we set the environment to load from templates/\n                # And then render the code from the module\n                templates_dir = str(importlib.resources.files(\"changedetectionio\").joinpath('templates'))\n                env = Environment(loader=FileSystemLoader(templates_dir))\n                template = env.from_string(form.extra_form_content())\n                included_content = template.render(**template_args)\n\n            output = render_template(\"edit.html\",\n                                     extra_tab_content=form.extra_tab_content() if form.extra_tab_content() else None,\n                                     extra_form_content=included_content,\n                                     **template_args\n                                     )\n\n        return output\n\n    @edit_blueprint.route(\"/edit/<uuid_str:uuid>/get-html\", methods=['GET'])\n    @login_optionally_required\n    def watch_get_latest_html(uuid):\n        from io import BytesIO\n        from flask import send_file\n        import brotli\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n        watch = datastore.data['watching'].get(uuid)\n        if watch and watch.history.keys() and os.path.isdir(watch.data_dir):\n            latest_filename = list(watch.history.keys())[-1]\n            html_fname = os.path.join(watch.data_dir, f\"{latest_filename}.html.br\")\n            with open(html_fname, 'rb') as f:\n                if html_fname.endswith('.br'):\n                    # Read and decompress the Brotli file\n                    decompressed_data = brotli.decompress(f.read())\n                else:\n                    decompressed_data = f.read()\n\n            buffer = BytesIO(decompressed_data)\n\n            return send_file(buffer, as_attachment=True, download_name=f\"{latest_filename}.html\", mimetype='text/html')\n\n        # Return a 500 error\n        abort(500)\n\n    @edit_blueprint.route(\"/edit/<uuid_str:uuid>/get-data-package\", methods=['GET'])\n    @login_optionally_required\n    def watch_get_data_package(uuid):\n        \"\"\"Download all data for a single watch as a zip file\"\"\"\n        from io import BytesIO\n        from flask import send_file\n        import zipfile\n        from pathlib import Path\n        import datetime\n\n        watch = datastore.data['watching'].get(uuid)\n        if not watch:\n            abort(404)\n\n        # Create zip in memory\n        memory_file = BytesIO()\n\n        with zipfile.ZipFile(memory_file, 'w',\n                           compression=zipfile.ZIP_DEFLATED,\n                           compresslevel=8) as zipObj:\n\n            # Add the watch's JSON file if it exists\n            watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n            if os.path.isfile(watch_json_path):\n                zipObj.write(watch_json_path,\n                           arcname=os.path.join(uuid, 'watch.json'),\n                           compress_type=zipfile.ZIP_DEFLATED,\n                           compresslevel=8)\n\n            # Add all files in the watch data directory\n            if os.path.isdir(watch.data_dir):\n                for f in Path(watch.data_dir).glob('*'):\n                    if f.is_file() and f.name != 'watch.json':  # Skip watch.json since we already added it\n                        zipObj.write(f,\n                                   arcname=os.path.join(uuid, f.name),\n                                   compress_type=zipfile.ZIP_DEFLATED,\n                                   compresslevel=8)\n\n        # Seek to beginning of file\n        memory_file.seek(0)\n\n        # Generate filename with timestamp\n        timestamp = datetime.datetime.now().strftime(\"%Y%m%d%H%M%S\")\n        filename = f\"watch-data-{uuid[:8]}-{timestamp}.zip\"\n\n        return send_file(memory_file,\n                        as_attachment=True,\n                        download_name=filename,\n                        mimetype='application/zip')\n\n    # Ajax callback\n    @edit_blueprint.route(\"/edit/<uuid_str:uuid>/preview-rendered\", methods=['POST'])\n    @login_optionally_required\n    def watch_get_preview_rendered(uuid):\n        '''For when viewing the \"preview\" of the rendered text from inside of Edit'''\n        from flask import jsonify\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n        from changedetectionio.processors.text_json_diff import prepare_filter_prevew\n        result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore)\n        return jsonify(result)\n\n    @edit_blueprint.route(\"/highlight_submit_ignore_url\", methods=['POST'])\n    @login_optionally_required\n    def highlight_submit_ignore_url():\n        import re\n        mode = request.form.get('mode')\n        selection = request.form.get('selection')\n\n        uuid = request.args.get('uuid','')\n        if datastore.data[\"watching\"].get(uuid):\n            if mode == 'exact':\n                for l in selection.splitlines():\n                    datastore.data[\"watching\"][uuid]['ignore_text'].append(l.strip())\n            elif mode == 'digit-regex':\n                for l in selection.splitlines():\n                    # Replace any series of numbers with a regex\n                    s = re.escape(l.strip())\n                    s = re.sub(r'[0-9]+', r'\\\\d+', s)\n                    datastore.data[\"watching\"][uuid]['ignore_text'].append('/' + s + '/')\n\n            # Save the updated ignore_text\n            datastore.data[\"watching\"][uuid].commit()\n\n        return f\"<a href={url_for('ui.ui_preview.preview_page', uuid=uuid)}>Click to preview</a>\"\n    \n    return edit_blueprint"
  },
  {
    "path": "changedetectionio/blueprint/ui/notification.py",
    "content": "from flask import Blueprint, request, make_response\nimport random\nfrom loguru import logger\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.auth_decorator import login_optionally_required\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    notification_blueprint = Blueprint('ui_notification', __name__, template_folder=\"../ui/templates\")\n    \n    # AJAX endpoint for sending a test\n    @notification_blueprint.route(\"/notification/send-test/<string:watch_uuid>\", methods=['POST'])\n    @notification_blueprint.route(\"/notification/send-test\", methods=['POST'])\n    @notification_blueprint.route(\"/notification/send-test/\", methods=['POST'])\n    @login_optionally_required\n    def ajax_callback_send_notification_test(watch_uuid=None):\n        from changedetectionio.notification_service import NotificationContextData, set_basic_notification_vars\n        # Watch_uuid could be unset in the case it`s used in tag editor, global settings\n        import apprise\n        from changedetectionio.notification.handler import process_notification\n        from changedetectionio.notification.apprise_plugin.assets import apprise_asset\n        from changedetectionio.jinja2_custom import render as jinja_render\n\n        from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler\n\n        apobj = apprise.Apprise(asset=apprise_asset)\n\n        is_global_settings_form = request.args.get('mode', '') == 'global-settings'\n        is_group_settings_form = request.args.get('mode', '') == 'group-settings'\n\n        # Use an existing random one on the global/main settings form\n        if not watch_uuid and (is_global_settings_form or is_group_settings_form) \\\n                and datastore.data.get('watching'):\n            logger.debug(f\"Send test notification - Choosing random Watch {watch_uuid}\")\n            watch_uuid = random.choice(list(datastore.data['watching'].keys()))\n\n        if not watch_uuid:\n            return make_response(\"Error: You must have atleast one watch configured for 'test notification' to work\", 400)\n\n        watch = datastore.data['watching'].get(watch_uuid)\n        notification_urls = request.form.get('notification_urls','').strip().splitlines()\n\n        if not notification_urls:\n            logger.debug(\"Test notification - Trying by group/tag in the edit form if available\")\n            # On an edit page, we should also fire off to the tags if they have notifications\n            if request.form.get('tags') and request.form['tags'].strip():\n                for k in request.form['tags'].split(','):\n                    tag = datastore.tag_exists_by_name(k.strip())\n                    notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None\n\n        if not notification_urls and not is_global_settings_form and not is_group_settings_form:\n            # In the global settings, use only what is typed currently in the text box\n            logger.debug(\"Test notification - Trying by global system settings notifications\")\n            if datastore.data['settings']['application'].get('notification_urls'):\n                notification_urls = datastore.data['settings']['application']['notification_urls']\n\n        if not notification_urls:\n            return 'Error: No Notification URLs set/found'\n\n        for n_url in notification_urls:\n            # We are ONLY validating the apprise:// part here, convert all tags to something so as not to break apprise URLs\n            generic_notification_context_data = NotificationContextData()\n            generic_notification_context_data.set_random_for_validation()\n            n_url = jinja_render(template_str=n_url, **generic_notification_context_data).strip()\n            if len(n_url.strip()):\n                if not apobj.add(n_url):\n                    return f'Error:  {n_url} is not a valid AppRise URL.'\n\n        try:\n            # use the same as when it is triggered, but then override it with the form test values\n            n_object = NotificationContextData({\n                'watch_url': request.form.get('window_url', \"https://changedetection.io\"),\n                'notification_urls': notification_urls\n            })\n\n            # Only use if present, if not set in n_object it should use the default system value\n            if 'notification_format' in request.form and request.form['notification_format'].strip():\n                n_object['notification_format'] = request.form.get('notification_format', '').strip()\n            else:\n                n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format')\n\n            if 'notification_title' in request.form and request.form['notification_title'].strip():\n                n_object['notification_title'] = request.form.get('notification_title', '').strip()\n            elif datastore.data['settings']['application'].get('notification_title'):\n                n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title')\n            else:\n                n_object['notification_title'] = \"Test title\"\n\n            if 'notification_body' in request.form and request.form['notification_body'].strip():\n                n_object['notification_body'] = request.form.get('notification_body', '').strip()\n            elif datastore.data['settings']['application'].get('notification_body'):\n                n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')\n            else:\n                n_object['notification_body'] = \"Test body\"\n\n            n_object['as_async'] = False\n\n            #  Same like in notification service, should be refactored\n            dates = list(watch.history.keys())\n            trigger_text = ''\n            snapshot_contents = ''\n\n            # Could be called as a 'test notification' with only 1 snapshot available\n            prev_snapshot = \"Example text: example test\\nExample text: change detection is cool\\nExample text: some more examples\\n\"\n            current_snapshot = \"Example text: example test\\nExample text: change detection is fantastic\\nExample text: even more examples\\nExample text: a lot more examples\"\n\n            if len(dates) > 1:\n                prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2])\n                current_snapshot = watch.get_history_snapshot(timestamp=dates[-1])\n\n            n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot,\n                                                        prev_snapshot=prev_snapshot,\n                                                        watch=watch,\n                                                        triggered_text=trigger_text,\n                                                        timestamp_changed=dates[-1] if dates else None))\n\n\n            sent_obj = process_notification(n_object, datastore)\n\n        except Exception as e:\n            logger.error(e)\n            e_str = str(e)\n            # Remove this text which is not important and floods the container\n            e_str = e_str.replace(\n                \"DEBUG - <class 'apprise.decorators.base.CustomNotifyPlugin.instantiate_plugin.<locals>.CustomNotifyPluginWrapper'>\",\n                '')\n\n            return make_response(e_str, 400)\n\n        return 'OK - Sent test notifications'\n\n    return notification_blueprint"
  },
  {
    "path": "changedetectionio/blueprint/ui/preview.py",
    "content": "from flask import Blueprint, request, url_for, flash, render_template, redirect\nfrom flask_babel import gettext\nimport time\nfrom loguru import logger\n\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.auth_decorator import login_optionally_required\nfrom changedetectionio import html_tools\n\ndef construct_blueprint(datastore: ChangeDetectionStore):\n    preview_blueprint = Blueprint('ui_preview', __name__, template_folder=\"../ui/templates\")\n\n\n    @preview_blueprint.route(\"/preview/<uuid_str:uuid>\", methods=['GET', 'POST'])\n    @login_optionally_required\n    def preview_page(uuid):\n        \"\"\"\n        Render the preview page for a watch.\n\n        This route is processor-aware: it delegates rendering to the processor's\n        preview.py module, allowing different processor types to provide\n        custom visualizations:\n        - text_json_diff: Text preview with syntax highlighting\n        - image_ssim_diff: Image preview with proper rendering\n        - restock_diff: Could show latest price/stock data\n\n        Each processor implements processors/{type}/preview.py::render()\n        If a processor doesn't have a preview module, falls back to default text preview.\n        \"\"\"\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n        try:\n            watch = datastore.data['watching'][uuid]\n        except KeyError:\n            flash(gettext(\"No history found for the specified link, bad link?\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        # Get the processor type for this watch\n        processor_name = watch.get('processor', 'text_json_diff')\n\n        # Try to get the processor's preview module (works for both built-in and plugin processors)\n        from changedetectionio.processors import get_processor_submodule\n        processor_module = get_processor_submodule(processor_name, 'preview')\n\n        # Call the processor's render() function\n        if processor_module and hasattr(processor_module, 'render'):\n            return processor_module.render(\n                watch=watch,\n                datastore=datastore,\n                request=request,\n                url_for=url_for,\n                render_template=render_template,\n                flash=flash,\n                redirect=redirect\n            )\n\n        # Fallback: if processor doesn't have preview module, use default text preview\n        content = []\n        versions = []\n        timestamp = None\n\n        extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]\n        is_html_webdriver = watch.fetcher_supports_screenshots\n\n        triggered_line_numbers = []\n        ignored_line_numbers = []\n        blocked_line_numbers = []\n\n        if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):\n            flash(gettext(\"Preview unavailable - No fetch/check completed or triggers not reached\"), \"error\")\n        else:\n            # So prepare the latest preview or not\n            preferred_version = request.values.get('version') if request.method == 'POST' else request.args.get('version')\n\n\n            versions = list(watch.history.keys())\n            timestamp = versions[-1]\n            if preferred_version and preferred_version in versions:\n                timestamp = preferred_version\n\n            try:\n                versions = list(watch.history.keys())\n                content = watch.get_history_snapshot(timestamp=timestamp)\n\n                triggered_line_numbers = html_tools.strip_ignore_text(content=content,\n                                                                      wordlist=watch.get('trigger_text'),\n                                                                      mode='line numbers'\n                                                                      )\n                ignored_line_numbers = html_tools.strip_ignore_text(content=content,\n                                                                      wordlist=watch.get('ignore_text'),\n                                                                      mode='line numbers'\n                                                                      )\n                blocked_line_numbers = html_tools.strip_ignore_text(content=content,\n                                                                      wordlist=watch.get(\"text_should_not_be_present\"),\n                                                                      mode='line numbers'\n                                                                      )\n            except Exception as e:\n                content.append({'line': f\"File doesnt exist or unable to read timestamp {timestamp}\", 'classes': ''})\n\n        from changedetectionio.pluggy_interface import get_fetcher_capabilities\n        capabilities = get_fetcher_capabilities(watch, datastore)\n\n        output = render_template(\"preview.html\",\n                                 capabilities=capabilities,\n                                 content=content,\n                                 current_diff_url=watch['url'],\n                                 current_version=timestamp,\n                                 extra_stylesheets=extra_stylesheets,\n                                 extra_title=f\" - Diff - {watch.label} @ {timestamp}\",\n                                 highlight_ignored_line_numbers=ignored_line_numbers,\n                                 highlight_triggered_line_numbers=triggered_line_numbers,\n                                 highlight_blocked_line_numbers=blocked_line_numbers,\n                                 history_n=watch.history_n,\n                                 is_html_webdriver=is_html_webdriver,\n                                 last_error=watch['last_error'],\n                                 last_error_screenshot=watch.get_error_snapshot(),\n                                 last_error_text=watch.get_error_text(),\n                                 screenshot=watch.get_screenshot(),\n                                 uuid=uuid,\n                                 versions=versions,\n                                 watch=watch,\n                                 )\n\n        return output\n\n    @preview_blueprint.route(\"/preview/<uuid_str:uuid>/processor-asset/<string:asset_name>\", methods=['GET'])\n    @login_optionally_required\n    def processor_asset(uuid, asset_name):\n        \"\"\"\n        Serve processor-specific binary assets for preview (images, files, etc.).\n\n        This route is processor-aware: it delegates to the processor's\n        preview.py module, allowing different processor types to serve\n        custom assets without embedding them as base64 in templates.\n\n        This solves memory issues with large binary data by streaming them\n        as separate HTTP responses instead of embedding in the HTML template.\n\n        Each processor implements processors/{type}/preview.py::get_asset()\n        which returns (binary_data, content_type, cache_control_header).\n\n        Example URLs:\n        - /preview/{uuid}/processor-asset/screenshot?version=123456789\n        \"\"\"\n        from flask import make_response\n\n        if uuid == 'first':\n            uuid = list(datastore.data['watching'].keys()).pop()\n        try:\n            watch = datastore.data['watching'][uuid]\n        except KeyError:\n            flash(gettext(\"No history found for the specified link, bad link?\"), \"error\")\n            return redirect(url_for('watchlist.index'))\n\n        # Get the processor type for this watch\n        processor_name = watch.get('processor', 'text_json_diff')\n\n        # Try to get the processor's preview module (works for both built-in and plugin processors)\n        from changedetectionio.processors import get_processor_submodule\n        processor_module = get_processor_submodule(processor_name, 'preview')\n\n        # Call the processor's get_asset() function\n        if processor_module and hasattr(processor_module, 'get_asset'):\n            result = processor_module.get_asset(\n                asset_name=asset_name,\n                watch=watch,\n                datastore=datastore,\n                request=request\n            )\n\n            if result is None:\n                from flask import abort\n                abort(404, description=f\"Asset '{asset_name}' not found\")\n\n            binary_data, content_type, cache_control = result\n\n            response = make_response(binary_data)\n            response.headers['Content-Type'] = content_type\n            if cache_control:\n                response.headers['Cache-Control'] = cache_control\n            return response\n        else:\n            logger.warning(f\"Processor {processor_name} does not implement get_asset()\")\n            from flask import abort\n            abort(404, description=f\"Processor '{processor_name}' does not support assets\")\n\n    return preview_blueprint\n"
  },
  {
    "path": "changedetectionio/blueprint/ui/templates/clear_all_history.html",
    "content": "{% extends 'base.html' %} {% block content %}\n<div class=\"edit-form\">\n  <div class=\"box-wrap inner\">\n    <form\n      class=\"pure-form pure-form-stacked\"\n      action=\"{{url_for('ui.clear_all_history')}}\"\n      method=\"POST\"\n    >\n      <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\" >\n      <fieldset>\n        <div class=\"pure-control-group\">\n          {{ _('This will remove version history (snapshots) for ALL watches, but keep your list of URLs!') }} <br />\n          {{ _('You may like to use the') }} <strong>{{ _('BACKUP') }}</strong> {{ _('link first.') }}<br />\n        </div>\n        <br />\n        <div class=\"pure-control-group\">\n          <label for=\"confirmtext\">{{ _('Confirmation text') }}</label>\n          <input\n            type=\"text\"\n            id=\"confirmtext\"\n            required=\"\"\n            name=\"confirmtext\"\n            value=\"\"\n            size=\"10\"\n          />\n          <span class=\"pure-form-message-inline\"\n            >{{ _('Type in the word') }} <strong>{{ _('clear') }}</strong> {{ _('to confirm that you understand.') }}</span\n          >\n        </div>\n        <br />\n        <div class=\"pure-control-group\">\n          <button type=\"submit\" class=\"pure-button pure-button-primary\">\n            {{ _('Clear History!') }}\n          </button>\n        </div>\n        <br />\n        <div class=\"pure-control-group\">\n          <a href=\"{{url_for('watchlist.index')}}\" class=\"pure-button button-cancel\"\n            >{{ _('Cancel') }}</a\n          >\n        </div>\n      </fieldset>\n    </form>\n  </div>\n</div>\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/ui/templates/diff-offscreen-options.html",
    "content": "<ul id=\"highlightSnippetActions\">\n    <li>\n        <button class=\"pure-button pure-button-primary\" onclick=\"diffToJpeg()\" title=\"{{ _('Share diff as image') }}\">{{ _('Share as Image') }}</button>\n    </li>\n    <li>\n        <a class=\"pure-button pure-button-primary\" data-mode=\"exact\" href=\"javascript:void(0);\">{{ _('Ignore any lines matching') }}</a>\n    </li>\n    <li>\n        <a class=\"pure-button pure-button-primary\" data-mode=\"digit-regex\" href=\"javascript:void(0);\" >{{ _('Ignore any lines matching excluding digits') }}</a>\n    </li>\n</ul>\n\n"
  },
  {
    "path": "changedetectionio/blueprint/ui/templates/diff.html",
    "content": "{% extends 'base.html' %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}\n{% block content %}\n<script>\n    const screenshot_url=\"{{url_for('static_content', group='screenshot', filename=uuid)}}\";\n    {% if last_error_screenshot %}\n    const error_screenshot_url=\"{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}\";\n    {% endif %}\n\n    const highlight_submit_ignore_url=\"{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}\";\n    const watch_url= {{watch_a.link|tojson}};\n\n    // Initial scroll position: if set, scroll to this line number in #difference on page load\n    const initialScrollToLineNumber = {{ initial_scroll_line_number|default('null') }};\n</script>\n<script src=\"https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js\"></script>\n<script src=\"{{url_for('static_content', group='js', filename='plugins.js')}}\"></script>\n<script src=\"https://cdn.jsdelivr.net/npm/piexifjs@1.0.6/piexif.min.js\"></script>\n<script src=\"{{url_for('static_content', group='js', filename='snippet-to-image.js')}}\"></script>\n<script src=\"{{url_for('static_content', group='js', filename='diff-overview.js')}}\" defer></script>\n\n\n<div id=\"settings\">\n    <form class=\"pure-form \" action=\"{{ url_for(\"ui.ui_diff.diff_history_page\", uuid=uuid) }}\" method=\"GET\" id=\"diff-form\">\n        <fieldset class=\"diff-fieldset\">\n            {% if versions|length >= 1 %}\n                <span style=\"white-space: nowrap;\">\n                <label id=\"change-from\" for=\"diff-from-version\" class=\"from-to-label\">{{ _('From') }}</label>\n                <select id=\"diff-from-version\" name=\"from_version\" class=\"needs-localtime\">\n                    {%- for version in versions|reverse -%}\n                        <option value=\"{{ version }}\" {% if version== from_version %} selected=\"\" {% endif %}>\n                            {{ version }}{#{% if loop.index == 2 %} (Previous){% endif %}#}\n                        </option>\n                    {%- endfor -%}\n                </select>\n                </span>\n                <span style=\"white-space: nowrap;\">\n                <label id=\"change-to\" for=\"diff-to-version\" class=\"from-to-label\">{{ _('To') }}</label>\n                <select id=\"diff-to-version\" name=\"to_version\" class=\"needs-localtime\">\n                    {%- for version in versions|reverse -%}\n                        <option value=\"{{ version }}\" {% if version== to_version %} selected=\"\" {% endif %}>\n                            {{ version }}{#{% if loop.first %} (Current){% endif %}#}\n                        </option>\n                    {%- endfor -%}\n                </select>\n                </span>\n                {#<button type=\"submit\" class=\"pure-button pure-button-primary reset-margin\">Go</button>#}\n            {% endif %}\n        </fieldset>\n        <fieldset id=\"diff-style\">\n            <span>\n                <label for=\"diffWords\" class=\"pure-checkbox\">\n                <input type=\"radio\" name=\"type\" id=\"diffWords\" value=\"diffWords\" {% if diff_prefs.type == 'diffWords' %}checked=\"\"{% endif %}> {{ _('Words') }}</label>\n            </span>\n            <span>\n                <label for=\"diffLines\" class=\"pure-checkbox\">\n                <input type=\"radio\" name=\"type\" id=\"diffLines\" value=\"diffLines\" {% if diff_prefs.type == 'diffLines' %}checked=\"\"{% endif %}> {{ _('Lines') }}</label>\n            </span>\n            <span>\n                <label for=\"ignoreWhitespace\" class=\"pure-checkbox\" id=\"label-diff-ignorewhitespace\">\n                <input type=\"checkbox\" id=\"ignoreWhitespace\" name=\"ignoreWhitespace\" {% if diff_prefs.ignoreWhitespace %}checked=\"\"{% endif %}> {{ _('Ignore Whitespace') }}</label>\n            </span>\n            <span>\n                <label for=\"changesOnly\" class=\"pure-checkbox\" id=\"label-diff-changes\">\n                <input type=\"checkbox\" id=\"changesOnly\" name=\"changesOnly\" {% if diff_prefs.changesOnly %}checked=\"\"{% endif %}> {{ _('Same/non-changed') }}</label>\n            </span>\n            <span>\n                <label for=\"removed\" class=\"pure-checkbox\" id=\"label-diff-removed\">\n                <input type=\"checkbox\" id=\"removed\" name=\"removed\" {% if diff_prefs.removed %}checked=\"\"{% endif %}> {{ _('Removed') }}</label>\n            </span>\n            <span>\n                <label for=\"added\" class=\"pure-checkbox\" id=\"label-diff-added\">\n                <input type=\"checkbox\" id=\"added\" name=\"added\" {% if diff_prefs.added %}checked=\"\"{% endif %}> {{ _('Added') }}</label>\n            </span>\n            <span>\n                <label for=\"replaced\" class=\"pure-checkbox\" id=\"label-diff-replaced\">\n                <input type=\"checkbox\" id=\"replaced\"  name=\"replaced\" {% if diff_prefs.replaced %}checked=\"\"{% endif %}> {{ _('Replaced') }}</label>\n            </span>\n        </fieldset>\n        {%- if versions|length >= 2 -%}\n            <div id=\"keyboard-nav\">\n                <strong>{{ _('Keyboard:') }} </strong>\n                <a href=\"\" class=\"pure-button pure-button-primary\" id=\"btn-previous\"> &larr; {{ _('Previous') }}</a>\n                &nbsp; <a class=\"pure-button pure-button-primary\" id=\"btn-next\" href=\"\"> &rarr; {{ _('Next') }}</a>\n            </div>\n        {%- endif -%}\n    </form>\n</div>\n\n<div id=\"diff-jump\" style=\"display:none;\"><!-- disabled for now -->\n    <a id=\"jump-next-diff\" title=\"{{ _('Jump to next difference') }}\">{{ _('Jump') }}</a>\n</div>\n\n<script src=\"{{url_for('static_content', group='js', filename='tabs.js')}}\" defer></script>\n<div class=\"tabs\">\n    <ul>\n        {% if last_error_text %}<li class=\"tab\" id=\"error-text-tab\"><a href=\"#error-text\">{{ _('Error Text') }}</a></li> {% endif %}\n        {% if last_error_screenshot %}<li class=\"tab\" id=\"error-screenshot-tab\"><a href=\"#error-screenshot\">{{ _('Error Screenshot') }}</a></li> {% endif %}\n        <li class=\"tab\" id=\"text-tab\"><a href=\"#text\">{{ _('Text') }}</a></li>\n        <li class=\"tab\" id=\"screenshot-tab\"><a href=\"#screenshot\">{{ _('Current screenshot') }}</a></li>\n        <li class=\"tab\" id=\"extract-tab\"><a href=\"{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}\">{{ _('Extract Data') }}</a></li>\n    </ul>\n</div>\n\n<div id=\"diff-ui\">\n    <div class=\"tab-pane-inner\" id=\"error-text\">\n        <div class=\"snapshot-age error\">{{watch_a.error_text_ctime|format_seconds_ago}} {{ _('seconds ago.') }}</div>\n        <pre>\n            {{ last_error_text }}\n        </pre>\n    </div>\n\n    <div class=\"tab-pane-inner\" id=\"error-screenshot\">\n        <div class=\"snapshot-age error\">{{watch_a.snapshot_error_screenshot_ctime|format_seconds_ago}} {{ _('seconds ago') }}</div>\n        <img id=\"error-screenshot-img\"  style=\"max-width: 80%\" alt=\"{{ _('Current error-ing screenshot from most recent request') }}\" >\n    </div>\n\n    <div class=\"tab-pane-inner\" id=\"text\">\n    {%- if (content | default('')).split('\\n') | length > 100 -%}\n        <div id=\"cell-diff-jump-visualiser\"  style=\"user-select: none;\">\n            {%- for cell in diff_cell_grid -%}\n            <div{% if cell.class %} class=\"{{ cell.class }}\"{% endif %}></div>\n            {%- endfor -%}\n        </div>\n    {%- endif -%}\n        {%- if password_enabled_and_share_is_off -%}\n            <div class=\"tip\">{{ _('Pro-tip: You can enable') }} <strong>{{ _('\"share access when password is enabled\"') }}</strong> {{ _('from settings.') }}\n            </div>\n        {%- endif -%}\n        <div id=\"text-diff-heading-area\"  style=\"user-select: none;\">\n            <div class=\"snapshot-age\"><span>{{ from_version|format_timestamp_timeago }}</span>\n                {%- if note -%}<span class=\"note\"><strong>{{ note }}</strong></span>{%- endif -%}\n                <a href=\"{{ url_for(\"ui.ui_preview.preview_page\", uuid=uuid) }}\">{{ _('Goto single snapshot') }}</a>\n            </div>\n        </div>\n        <pre id=\"difference\" style=\"border-left: 2px solid #ddd;\">{{ content| diff_unescape_difference_spans }}</pre>\n    <div id=\"diff-visualiser-area-after\" style=\"user-select: none;\">\n        <strong>{{ _('Tip:') }}</strong> {{ _('Highlight text to share or add to ignore lists.') }}\n    </div>\n    </div>\n\n    <div class=\"tab-pane-inner\" id=\"screenshot\">\n         <div class=\"tip\">\n             {{ _('For now, Differences are performed on text, not graphically, only the latest screenshot is available.') }}\n         </div>\n         {% if is_html_webdriver %}\n           {% if screenshot %}\n            <div class=\"snapshot-age\">{{watch_a.snapshot_screenshot_ctime|format_timestamp_timeago}}</div>\n            <img style=\"max-width: 80%\" id=\"screenshot-img\" alt=\"{{ _('Current screenshot from most recent request') }}\" >\n           {% else %}\n              {{ _('No screenshot available just yet! Try rechecking the page.') }}\n           {% endif %}\n         {% else %}\n           <strong>{{ _('Screenshot requires Playwright/WebDriver enabled') }}</strong>\n         {% endif %}\n     </div>\n\n</div>\n\n<script>\n    const newest_version_timestamp = {{newest_version_timestamp}};\n</script>\n<script src=\"{{url_for('static_content', group='js', filename='diff-render.js')}}\"></script>\n\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/ui/templates/edit.html",
    "content": "{% extends 'base.html' %}\n{% block content %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_playwright_type_watches_warning, highlight_trigger_ignored_explainer, render_conditions_fieldlist_of_formfields_as_table, render_ternary_field %}\n{% from '_common_fields.html' import render_common_settings_form %}\n<script src=\"{{url_for('static_content', group='js', filename='tabs.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='vis.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='global-settings.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='scheduler.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='conditions.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='modal.js')}}\"></script>\n\n\n<script>\n    const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');\n    const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');\n    <!-- Should be _external so that firefox and others load it more reliably -->\n    const browser_steps_fetch_screenshot_image_url=\"{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid, _external=True)}}\";\n    const browser_steps_last_error_step={{ watch.browser_steps_last_error_step|tojson }};\n    const browser_steps_start_url=\"{{url_for('browser_steps.browsersteps_start_session', uuid=uuid)}}\";\n    const browser_steps_sync_url=\"{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}\";\n{% if emailprefix %}\n    const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}');\n{% endif %}\n    const notification_base_url=\"{{url_for('ui.ui_notification.ajax_callback_send_notification_test', watch_uuid=uuid)}}\";\n    const playwright_enabled={% if playwright_enabled %}true{% else %}false{% endif %};\n    const recheck_proxy_start_url=\"{{url_for('check_proxies.start_check', uuid=uuid)}}\";\n    const proxy_recheck_status_url=\"{{url_for('check_proxies.get_recheck_status', uuid=uuid)}}\";\n    const screenshot_url=\"{{url_for('static_content', group='screenshot', filename=uuid)}}\";\n    const watch_visual_selector_data_url=\"{{url_for('static_content', group='visual_selector_data', filename=uuid)}}\";\n    const default_system_fetch_backend=\"{{ settings_application['fetch_backend'] }}\";\n</script>\n<script src=\"{{url_for('static_content', group='js', filename='plugins.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='watch-settings.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='notifications.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='visual-selector.js')}}\" defer></script>\n{% if playwright_enabled %}\n<script src=\"{{url_for('static_content', group='js', filename='browser-steps.js')}}\" defer></script>\n{% endif %}\n\n{% set has_tag_filters_extra=\"WARNING: Watch has tag/groups set with special filters\\n\" if has_special_tag_options else '' %}\n<script src=\"{{url_for('static_content', group='js', filename='recheck-proxy.js')}}\" defer></script>\n\n<div class=\"edit-form monospaced-textarea\">\n\n    <div class=\"tabs collapsable\">\n        <ul>\n            <li class=\"tab\"><a href=\"#general\">{{ _('General') }}</a></li>\n            {% if capabilities.supports_request_type %}\n            <li class=\"tab\"><a href=\"#request\">{{ _('Request') }}</a></li>\n            {% endif %}\n            {% if extra_tab_content %}\n            <li class=\"tab\"><a href=\"#extras_tab\">{{ extra_tab_content }}</a></li>\n            {% endif %}\n            {% if capabilities.supports_browser_steps %}\n            <li class=\"tab\"><a id=\"browsersteps-tab\" href=\"#browser-steps\">{{ _('Browser Steps') }}</a></li>\n            {% endif %}\n            {% if capabilities.supports_visual_selector %}\n            <li class=\"tab\"><a id=\"visualselector-tab\" href=\"#visualselector\">{{ _('Visual Filter Selector') }}</a></li>\n            {% endif %}\n            {% if capabilities.supports_text_filters_and_triggers %}\n            <li class=\"tab\" id=\"filters-and-triggers-tab\"><a href=\"#filters-and-triggers\">{{ _('Filters & Triggers') }}</a></li>\n            <li class=\"tab\" id=\"conditions-tab\"><a href=\"#conditions\">{{ _('Conditions') }}</a></li>\n            {% endif %}\n            <li class=\"tab\"><a href=\"#notifications\">{{ _('Notifications') }}</a></li>\n            <li class=\"tab\"><a href=\"#stats\">{{ _('Stats') }}</a></li>\n        </ul>\n    </div>\n\n    <div class=\"box-wrap inner\">\n        <form class=\"pure-form pure-form-stacked\"\n              action=\"{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next = request.args.get('next'), unpause_on_save = request.args.get('unpause_on_save'), tag = request.args.get('tag')) }}\" method=\"POST\">\n             <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\">\n\n            <div class=\"tab-pane-inner\" id=\"general\">\n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.url, placeholder=\"https://...\", required=true, class=\"m-d\") }}\n                        <div class=\"pure-form-message\">{{ _('Some sites use JavaScript to create the content, for this you should') }} <a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver\">{{ _('use the Chrome/WebDriver Fetcher') }}</a></div>\n                        <div class=\"pure-form-message\">{{ _('Variables are supported in the URL') }} (<a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL\">{{ _('help and examples here') }}</a>).</div>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.tags) }}\n                        <span class=\"pure-form-message-inline\">{{ _('Organisational tag/group name used in the main listing page') }}</span>\n                    </div>\n                    <div class=\"pure-control-group inline-radio\">\n                        {{ render_field(form.processor) }}\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.title, class=\"m-d\", placeholder=watch.label) }}\n                        <span class=\"pure-form-message-inline\">{{ _('Automatically uses the page title if found, you can also use your own title/description here') }}</span>\n                    </div>\n                    <div class=\"pure-control-group time-between-check border-fieldset\">\n\n                        {{ render_checkbox_field(form.time_between_check_use_default, class=\"use-default-timecheck\") }}\n                        <br>\n                        <div id=\"time-check-widget-wrapper\">\n                            {{ render_field(form.time_between_check, class=\"time-check-widget\") }}\n\n                            <span class=\"pure-form-message-inline\">\n                             {{ _('The interval/amount of time between each check.') }}\n                            </span>\n                        </div>\n                        <div id=\"time-between-check-schedule\">\n                            <!-- Start Time and End Time -->\n                            <div id=\"limit-between-time\">\n                                {{ render_time_schedule_form(form, available_timezones, timezone_default_config) }}\n                            </div>\n                        </div>\n<br>\n              </div>\n\n                    <div class=\"pure-control-group\">\n                        {{ render_checkbox_field(form.filter_failure_notification_send) }}\n                        <span class=\"pure-form-message-inline\">\n                         {{ _('Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore.') }}\n                        </span>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.history_snapshot_max_length, class=\"history_snapshot_max_length\") }}\n                        <span class=\"pure-form-message-inline\">{{ _('Limit collection of history snapshots for each watch to this number of history items.') }}\n                        <br>\n                        {{ _('Set to empty to use system settings default') }}\n                        </span>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        {{ render_ternary_field(form.use_page_title_in_list) }}\n                    </div>\n                </fieldset>\n            </div>\n\n            {% if capabilities.supports_request_type %}\n            <div class=\"tab-pane-inner\" id=\"request\">\n                    <div class=\"pure-control-group inline-radio\">\n                        {{ render_field(form.fetch_backend, class=\"fetch-backend\") }}\n                        <span class=\"pure-form-message-inline\">\n                            <p>{{ _('Use the') }} <strong>{{ _('Basic') }}</strong> {{ _('method (default) where your watched site doesn\\'t need Javascript to render.') }}</p>\n                            <p>{{ _('The') }} <strong>{{ _('Chrome/Javascript') }}</strong> {{ _('method requires a network connection to a running WebDriver+Chrome server, set by the ENV var \\'WEBDRIVER_URL\\'.') }} </p>\n                            {{ _('Tip:') }} <a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support\">{{ _('Connect using Bright Data and Oxylabs Proxies, find out more here.') }}</a>\n                        </span>\n                    </div>\n                {% if form.proxy %}\n                    <div class=\"pure-control-group inline-radio\">\n                          <div>{{ form.proxy.label }} <a href=\"\" id=\"check-all-proxies\" class=\"pure-button button-secondary button-xsmall\" >{{ _('Check/Scan all') }}</a></div>\n                          <div>{{ form.proxy(class=\"fetch-backend-proxy\") }}</div>\n                        <span class=\"pure-form-message-inline\">\n                        {{ _('Choose a proxy for this watch') }}\n                        </span>\n                    </div>\n                {% endif %}\n\n                <!-- webdriver always -->\n                <fieldset data-visible-for=\"fetch_backend=html_webdriver\"  style=\"display: none;\">\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.webdriver_delay) }}\n                        <div class=\"pure-form-message-inline\">\n                            <strong>{{ _('If you\\'re having trouble waiting for the page to be fully rendered (text missing etc), try increasing the \\'wait\\' time here.') }}</strong>\n                            <br>\n                            {{ _('This will wait') }} <i>n</i> {{ _('seconds before extracting the text.') }}\n                            {% if using_global_webdriver_wait %}\n                            <br><strong>{{ _('Using the current global default settings') }}</strong>\n                            {% endif %}\n                        </div>\n                    </div>\n                    <div class=\"pure-control-group\">\n                        <a class=\"pure-button button-secondary button-xsmall show-advanced\">{{ _('Show advanced options') }}</a>\n                    </div>\n                    <div class=\"advanced-options\"  style=\"display: none;\">\n                        {{ render_field(form.webdriver_js_execute_code) }}\n                        <div class=\"pure-form-message-inline\">\n                            {{ _('Run this code before performing change detection, handy for filling in fields and other actions') }} <a\n                                href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Run-JavaScript-before-change-detection\">{{ _('More help and examples here') }}</a>\n                        </div>\n                    </div>\n                </fieldset>\n                <!-- html requests always -->\n                <fieldset data-visible-for=\"fetch_backend=html_requests\">\n                    <div class=\"pure-control-group\">\n                        <a class=\"pure-button button-secondary button-xsmall show-advanced\">{{ _('Show advanced options') }}</a>\n                    </div>\n                    <div class=\"advanced-options\"  style=\"display: none;\">\n                        <div class=\"pure-control-group\" id=\"request-method\">\n                            {{ render_field(form.method) }}\n                        </div>\n                        <div id=\"request-body\">\n                                            {{ render_field(form.body, rows=7, placeholder=\"Example\n{\n   \\\"name\\\":\\\"John\\\",\n   \\\"age\\\":30,\n   \\\"car\\\":null,\n   \\\"year\\\":{% now 'Europe/Berlin', '%Y' %}\n}\") }}\n                        </div>\n                        <div class=\"pure-form-message\">{{ _('Variables are supported in the request body') }} (<a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL\">{{ _('help and examples here') }}</a>).</div>\n                    </div>\n                </fieldset>\n            <!-- hmm -->\n                <div class=\"pure-control-group advanced-options\"  style=\"display: none;\">\n                    {{ render_field(form.headers, rows=7, placeholder=\"Example\nCookie: foobar\nUser-Agent: wonderbra 1.0\nMath: {{ 1 + 1 }}\") }}\n                        <div class=\"pure-form-message\">{{ _('Variables are supported in the request header values') }} (<a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Handling-variables-in-the-watched-URL\">{{ _('help and examples here') }}</a>).</div>\n                        <div class=\"pure-form-message-inline\">\n                            {% if has_extra_headers_file %}\n                                <strong>{{ _('Alert! Extra headers file found and will be added to this watch!') }}</strong>\n                            {% else %}\n                                {{ _('Headers can be also read from a file in your data-directory') }} <a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Adding-headers-from-an-external-file\">{{ _('Read more here') }}</a>\n                            {% endif %}\n                            <br>\n                            ({{ _('Not supported by Selenium browser') }})\n                        </div>\n                    </div>\n            <fieldset data-visible-for=\"fetch_backend=html_requests fetch_backend=html_webdriver\" >\n                    <div class=\"pure-control-group inline-radio advanced-options\"  style=\"display: none;\">\n                    {{ render_checkbox_field(form.ignore_status_codes) }}\n                    </div>\n            </fieldset>\n            </div>\n            {% endif %}\n\n            <div class=\"tab-pane-inner\" id=\"browser-steps\">\n            {% if capabilities.supports_browser_steps %}\n               {% if true %}\n                <img class=\"beta-logo\" src=\"{{url_for('static_content', group='images', filename='beta-logo.png')}}\" alt=\"New beta functionality\">\n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        <!--\n                        Too hard right now, better to just send the events to the fetcher for now and leave it in the final screenshot\n                        and/or report an error\n                        <a id=\"play-steps\" class=\"pure-button button-secondary button-xsmall\" style=\"font-size: 70%\">Play steps  ▶</a>\n                        -->\n\n                        <!---  Do this later -->\n                        <div class=\"checkbox\" style=\"display: none;\">\n                            <input type=checkbox id=\"include_text_elements\" > <label for=\"include_text_elements\">{{ _('Turn on text finder') }}</label>\n                        </div>\n\n                        <div id=\"loading-status-text\" style=\"display: none;\">{{ _('Please wait, first browser step can take a little time to load..') }}<div class=\"spinner\"></div></div>\n                        <div class=\"flex-wrapper\" >\n\n                            <div id=\"browser-steps-ui\" class=\"noselect\">\n                                <div class=\"noselect\"  id=\"browsersteps-selector-wrapper\" style=\"width: 100%\">\n                                    <span class=\"loader\" >\n                                        <span id=\"browsersteps-click-start\">\n                                            <h2 >{{ _('Click here to Start') }}</h2>\n                                            <svg style=\"height: 3.5rem;\" version=\"1.1\" viewBox=\"0 0 32 32\"  xml:space=\"preserve\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"><g id=\"start\"/><g id=\"play_x5F_alt\"><path d=\"M16,0C7.164,0,0,7.164,0,16s7.164,16,16,16s16-7.164,16-16S24.836,0,16,0z M10,24V8l16.008,8L10,24z\" style=\"fill: var(--color-grey-400);\"/></g></svg><br>\n                                            {{ _('Please allow 10-15 seconds for the browser to connect.') }}<br>\n                                        </span>\n                                        <div class=\"spinner\"  style=\"display: none;\"></div>\n                                    </span>\n                                    <img class=\"noselect\" id=\"browsersteps-img\" src=\"\" style=\"max-width: 100%; width: 100%;\" >\n                                    <canvas  class=\"noselect\" id=\"browsersteps-selector-canvas\" style=\"max-width: 100%; width: 100%;\"></canvas>\n                                </div>\n                            </div>\n                            <div id=\"browser-steps-fieldlist\" >\n                                <span id=\"browser-seconds-remaining\">{{ _('Press \"Play\" to start.') }}</span> <span style=\"font-size: 80%;\"> (<a target=\"newwindow\" href=\"https://github.com/dgtlmoon/changedetection.io/pull/478/files#diff-1a79d924d1840c485238e66772391268a89c95b781d69091384cf1ea1ac146c9R4\">?</a>) </span>\n                                {{ render_field(form.browser_steps) }}\n                            </div>\n                        </div>\n                    </div>\n                </fieldset>\n                {% else %}\n                    <strong>{{ _('Visual Selector data is not ready, watch needs to be checked atleast once.') }}</strong>\n                {% endif %}\n            {% else %}\n                <p>\n                    <strong>{{ _('Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)') }}<br>\n                    {{ _('You need to') }} <a href=\"#request\">{{ _('Set the fetch method') }}</a> {{ _('to one that supports interactive Javascript.') }}</strong>\n                </p>\n            {% endif %}\n            </div>\n\n\n            <div class=\"tab-pane-inner\" id=\"notifications\">\n                <fieldset>\n                    <div  class=\"pure-control-group inline-radio\">\n                      {{ render_ternary_field(form.notification_muted, BooleanField=true) }}\n                    </div>\n                    {% if capabilities.supports_screenshots %}\n                    <div class=\"pure-control-group inline-radio\">\n                      {{ render_checkbox_field(form.notification_screenshot) }}\n                        <span class=\"pure-form-message-inline\">\n                            <strong>{{ _('Use with caution!') }}</strong> {{ _('This will easily fill up your email storage quota or flood other storages.') }}\n                        </span>\n                    </div>\n                    {% endif %}\n                    <div class=\"field-group\" id=\"notification-field-group\">\n                        {% if has_default_notification_urls %}\n                        <div class=\"inline-warning\">\n                            <img class=\"inline-warning-icon\" src=\"{{url_for('static_content', group='images', filename='notice.svg')}}\" alt=\"{{ _('Look out!') }}\" title=\"{{ _('Lookout!') }}\" >\n                            {{ _('There are') }} <a href=\"{{ url_for('settings.settings_page')}}#notifications\">{{ _('system-wide notification URLs enabled') }}</a>, {{ _('this form will override notification settings for this watch only') }} &dash; {{ _('an empty Notification URL list here will still send notifications.') }}\n                        </div>\n                        {% endif %}\n                        <a href=\"#notifications\" id=\"notification-setting-reset-to-default\" class=\"pure-button button-xsmall\" style=\"right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff\">{{ _('Use system defaults') }}</a>\n                        {{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}\n                    </div>\n                </fieldset>\n            </div>\n\n            {% if capabilities.supports_text_filters_and_triggers %}\n            <div class=\"tab-pane-inner\" id=\"conditions\">\n                    <script>\n                        const verify_condition_rule_url=\"{{url_for('conditions.verify_condition_single_rule', watch_uuid=uuid)}}\";\n                    </script>\n                <div class=\"pure-control-group\">\n                    {{ render_field(form.conditions_match_logic) }}\n                    {{ render_conditions_fieldlist_of_formfields_as_table(form.conditions) }}\n                    <div class=\"pure-form-message-inline\">\n\n                        <p id=\"verify-state-text\">{{ _('Use the verify (✓) button to test if a condition passes against the current snapshot.') }}</p>\n                       {{ _('Read a quick tutorial about') }} <a href=\"https://changedetection.io/tutorial/conditional-actions-web-page-changes\">{{ _('using conditional web page changes here') }}</a>.<br>\n                    </div>\n                </div>\n            </div>\n            <div class=\"tab-pane-inner\" id=\"filters-and-triggers\">\n                <span id=\"activate-text-preview\" class=\"pure-button pure-button-primary button-xsmall\">{{ _('Activate preview') }}</span>\n              <div>\n              <div id=\"edit-text-filter\">\n\n{% if capabilities.supports_text_filters_and_triggers_elements %}\n                        <div class=\"pure-control-group\" id=\"pro-tips\">\n                            <strong>{{ _('Pro-tips:') }}</strong><br>\n                            <ul>\n                                <li>\n                                    {{ _('Use the preview page to see your filters and triggers highlighted.') }}\n                                </li>\n                                <li>\n                                    {{ _('Some sites use JavaScript to create the content, for this you should') }} <a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver\">{{ _('use the Chrome/WebDriver Fetcher') }}</a>\n                                </li>\n                            </ul>\n                    </div>\n{% include \"edit/include_subtract.html\" %}\n{% endif %}\n                <div class=\"text-filtering border-fieldset\">\n                <fieldset class=\"pure-group\" id=\"text-filtering-type-options\">\n                    <h3>{{ _('Text filtering') }}</h3>\n                        {{ _('Limit trigger/ignore/block/extract to;') }}<br>\n                        {{ render_checkbox_field(form.filter_text_added) }}\n                        {{ render_checkbox_field(form.filter_text_replaced) }}\n                        {{ render_checkbox_field(form.filter_text_removed) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Note: Depending on the length and similarity of the text on each line, the algorithm may consider an') }} <strong>{{ _('addition') }}</strong> {{ _('instead of') }} <strong>{{ _('replacement') }}</strong> {{ _('for example.') }}</span><br>\n                    <span class=\"pure-form-message-inline\">&nbsp;{{ _('So it\\'s always better to select') }} <strong>{{ _('Added') }}</strong>+<strong>{{ _('Replaced') }}</strong> {{ _('when you\\'re interested in new content.') }}</span><br>\n                    <span class=\"pure-form-message-inline\">&nbsp;{{ _('When content is merely moved in a list, it will also trigger an') }} <strong>{{ _('addition') }}</strong>, {{ _('consider enabling') }} <code><strong>{{ _('Only trigger when unique lines appear') }}</strong></code></span>\n                </fieldset>\n                <fieldset class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.check_unique_lines) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.') }}</span>\n                </fieldset>\n                <fieldset class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.remove_duplicate_lines) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Remove duplicate lines of text') }}</span>\n                </fieldset>\n                <fieldset class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.sort_text_alphabetically) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Helps reduce changes detected caused by sites shuffling lines around, combine with') }} <i>{{ _('check unique lines') }}</i> {{ _('below.') }}</span>\n                </fieldset>\n                <fieldset class=\"pure-control-group\">\n                    {{ render_checkbox_field(form.trim_text_whitespace) }}\n                    <span class=\"pure-form-message-inline\">{{ _('Remove any whitespace before and after each line of text') }}</span>\n                </fieldset>\n                {% include \"edit/text-options.html\" %}\n                </div>\n              </div>\n              <div id=\"text-preview\" style=\"display: none;\" >\n                <script>\n                    const preview_text_edit_filters_url=\"{{url_for('ui.ui_edit.watch_get_preview_rendered', uuid=uuid)}}\";\n                </script>\n                <br>\n                {#<div id=\"text-preview-controls\"><span id=\"text-preview-refresh\" class=\"pure-button button-xsmall\">Refresh</span></div>#}\n                <div class=\"minitabs-wrapper\">\n                  <div class=\"minitabs-content\">\n                      <div id=\"text-preview-inner\" class=\"monospace-preview\">\n                          <p>{{ _('Loading...') }}</p>\n                      </div>\n                      <div id=\"text-preview-before-inner\" style=\"display: none;\" class=\"monospace-preview\">\n                          <p>{{ _('Loading...') }}</p>\n                      </div>\n                  </div>\n                </div>\n                {{ highlight_trigger_ignored_explainer() }}\n            </div>\n          </div>\n        </div>\n\n        {% endif %}\n        {# rendered sub Template #}\n        {% if extra_form_content %}\n            <div class=\"tab-pane-inner\" id=\"extras_tab\">\n            {{ extra_form_content|safe }}\n            </div>\n        {% endif %}\n            {% if capabilities.supports_visual_selector %}\n            <div class=\"tab-pane-inner visual-selector-ui\" id=\"visualselector\">\n                <img class=\"beta-logo\" src=\"{{url_for('static_content', group='images', filename='beta-logo.png')}}\" alt=\"New beta functionality\">\n\n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        {% if capabilities.supports_screenshots and capabilities.supports_xpath_element_data %}\n                            {% if visual_selector_data_ready %}\n                                <span class=\"pure-form-message-inline\" id=\"visual-selector-heading\">\n                                    {{ _('The Visual Selector tool lets you select the') }} <i>{{ _('text') }}</i> {{ _('elements that will be used for the change detection. It automatically fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the') }} <a href=\"#filters-and-triggers\">{{ _('Filters & Triggers') }}</a> {{ _('tab. Use') }} <strong>{{ _('Shift+Click') }}</strong> {{ _('to select multiple items.') }}\n                                </span>\n\n                                {% if watch['processor'] == 'image_ssim_diff' %} {# @todo, integrate with image_ssim_diff selector better, use some extra form ? #}\n                                <div id=\"selection-mode-controls\" style=\"margin: 10px 0; padding: 10px; background: var(--color-background-tab); border-radius: 5px;\">\n                                    <label style=\"font-weight: 600; margin-right: 15px;\">{{ _('Selection Mode:') }}</label>\n                                    <label style=\"margin-right: 15px;\">\n                                        <input type=\"radio\" name=\"selector-mode\" value=\"element\" style=\"margin-right: 5px;\">\n                                        {{ _('Select by element') }}\n                                    </label>\n                                    <label>\n                                        <input type=\"radio\" name=\"selector-mode\" value=\"draw\" checked style=\"margin-right: 5px;\">\n                                        {{ _('Draw area') }}\n                                    </label>\n                                    {{ render_field(form.processor_config_bounding_box) }}\n                                    {{ render_field(form.processor_config_selection_mode) }}\n                                </div>\n                                {% endif %}\n\n                                <div id=\"selector-header\">\n                                    <a id=\"clear-selector\" class=\"pure-button button-secondary button-xsmall\" style=\"font-size: 70%\">{{ _('Clear selection') }}</a>\n                                    <!-- visual selector IMG will try to load, it will either replace this or on error replace it with some handy text -->\n                                    <i class=\"fetching-update-notice\" style=\"font-size: 80%;\">{{ _('One moment, fetching screenshot and element information..') }}</i>\n                                </div>\n                                <div id=\"selector-wrapper\" style=\"display: none\">\n                                    <!-- request the screenshot and get the element offset info ready -->\n                                    <!-- use img src ready load to know everything is ready to map out -->\n                                    <!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' -->\n                                    <img id=\"selector-background\" >\n                                    <canvas id=\"selector-canvas\"></canvas>\n                                </div>\n                                <div id=\"selector-current-xpath\" style=\"overflow-x: hidden\"><strong>{{ _('Currently:') }}</strong>&nbsp;<span class=\"text\">{{ _('Loading...') }}</span></div>\n                            {%  else %}\n                                <strong>{{ _('Visual Selector data is not ready, watch needs to be checked atleast once.') }}</strong>\n                            {%  endif %}\n                    {% else %}\n                        <p>\n                            <strong>{{ _('Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).') }}<br>\n                            {{ _('You need to') }} <a href=\"#request\">{{ _('Set the fetch method') }}</a> {{ _('to one that supports Javascript and screenshots.') }}</strong>\n                        </p>\n                    {% endif %}\n                    </div>\n                </fieldset>\n            </div>\n            {% endif %}\n            <div class=\"tab-pane-inner\" id=\"stats\">\n                <div class=\"pure-control-group\">\n                    <style>\n                    #stats-table tr > td:first-child {\n                        font-weight: bold;\n                    }\n                    </style>\n                    <table class=\"pure-table\" id=\"stats-table\">\n                        <tbody>\n                        <tr>\n                            <td>{{ _('Check count') }}</td>\n                            <td>{{ \"{:,}\".format( watch.check_count) }}</td>\n                        </tr>\n                        <tr>\n                            <td>{{ _('Consecutive filter failures') }}</td>\n                            <td>{{ \"{:,}\".format( watch.consecutive_filter_failures) }}</td>\n                        </tr>\n                        <tr>\n                            <td>{{ _('History length') }}</td>\n                            <td>{{ \"{:,}\".format(watch.history|length) }}</td>\n                        </tr>\n                        <tr>\n                            <td>{{ _('Last fetch duration') }}</td>\n                            <td>{{ watch.fetch_time }}s</td>\n                        </tr>\n                        <tr>\n                            <td>{{ _('Notification alert count') }}</td>\n                            <td>{{ watch.notification_alert_count }}</td>\n                        </tr>\n                        <tr>\n                            <td>{{ _('Server type reply') }}</td>\n                            <td>{{ watch.get('remote_server_reply') }}</td>\n                        </tr>\n                        </tbody>\n                    </table>\n\n                    {% if ui_edit_stats_extras %}\n                    <div class=\"plugin-stats-extras\"> <!-- from pluggy plugin -->\n                        {{ ui_edit_stats_extras|safe }}\n                    </div>\n                    {% endif %}\n\n                    {% if watch.history_n %}\n                        <p>\n                             <a href=\"{{url_for('ui.ui_edit.watch_get_latest_html', uuid=uuid)}}\" class=\"pure-button button-small\">{{ _('Download latest HTML snapshot') }}</a>\n                             <a href=\"{{url_for('ui.ui_edit.watch_get_data_package', uuid=uuid)}}\" class=\"pure-button button-small\">{{ _('Download watch data package') }}</a>\n                        </p>\n                    {% endif %}\n\n                </div>\n            </div>\n            <div id=\"actions\">\n                <div class=\"pure-control-group\">\n                    {{ render_button(form.save_button) }}\n                    <a href=\"{{url_for('ui.form_delete', uuid=uuid)}}\"\n                       class=\"pure-button button-error\"\n                       data-requires-confirm\n                       data-confirm-type=\"danger\"\n                       data-confirm-title=\"{{ _('Delete Watch?') }}\"\n                       data-confirm-message=\"<p>{{ _('Are you sure you want to delete the watch for:') }}</p><p><strong>{{ watch.get('url', 'this watch') }}</strong></p><p>{{ _('This action cannot be undone.') }}</p>\"\n                       data-confirm-button=\"{{ _('Delete') }}\">{{ _('Delete') }}</a>\n                    {% if watch.history_n %}<a href=\"{{url_for('ui.clear_watch_history', uuid=uuid)}}\"\n                       class=\"pure-button button-error\"\n                       data-requires-confirm\n                       data-confirm-type=\"warning\"\n                       data-confirm-title=\"{{ _('Clear History?') }}\"\n                       data-confirm-message=\"<p>{{ _('Are you sure you want to clear all history for:') }}</p><p><strong>{{ watch.get('url', 'this watch') }}</strong></p><p>{{ _('This will remove all snapshots and previous versions. This action cannot be undone.') }}</p>\"\n                       data-confirm-button=\"{{ _('Clear History') }}\">{{ _('Clear History') }}</a>{% endif %}\n                    <a href=\"{{url_for('ui.form_clone', uuid=uuid)}}\"\n                       class=\"pure-button\">{{ _('Clone & Edit') }}</a>\n                 <a href=\"{{ url_for('rss.rss_single_watch', uuid=uuid, token=app_rss_token)}}\"><img alt=\"{{ _('RSS Feed for this watch') }}\" style=\"padding: .5em 1em;\" src=\"{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}\" height=\"15\"></a>\n                </div>\n            </div>\n        </form>\n    </div>\n</div>\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/ui/templates/preview.html",
    "content": "{% extends 'base.html' %}\n{% from '_helpers.html' import highlight_trigger_ignored_explainer %}\n{% block content %}\n    <script>\n        const screenshot_url = \"{{url_for('static_content', group='screenshot', filename=uuid)}}\";\n        const triggered_line_numbers = {{ highlight_triggered_line_numbers|tojson }};\n        const ignored_line_numbers = {{ highlight_ignored_line_numbers|tojson }};\n        const blocked_line_numbers = {{ highlight_blocked_line_numbers|tojson }};\n        {% if last_error_screenshot %}\n            const error_screenshot_url = \"{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}\";\n        {% endif %}\n        const highlight_submit_ignore_url = \"{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}\";\n    </script>\n    <script src=\"{{url_for('static_content', group='js', filename='plugins.js')}}\"></script>\n    <script src=\"{{ url_for('static_content', group='js', filename='diff-overview.js') }}\" defer></script>\n    <script src=\"{{ url_for('static_content', group='js', filename='preview.js') }}\" defer></script>\n    <script src=\"{{ url_for('static_content', group='js', filename='tabs.js') }}\" defer></script>\n    {% if versions|length >= 2 %}\n        <div id=\"diff-form\" style=\"text-align: center;\">\n            <form class=\"pure-form \"  action=\"{{url_for('ui.ui_preview.preview_page', uuid=uuid)}}\" method=\"POST\">\n                <fieldset>\n                    <label for=\"preview-version\">{{ _('Select timestamp') }}</label> <select id=\"preview-version\"\n                                                                                 name=\"from_version\"\n                                                                                 class=\"needs-localtime\">\n                    {% for version in versions|reverse %}\n                        <option value=\"{{ version }}\" {% if version == current_version %} selected=\"\" {% endif %}>\n                            {{ version }}\n                        </option>\n                    {% endfor %}\n                </select>\n                    <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\">\n                    <button type=\"submit\" class=\"pure-button pure-button-primary\">{{ _('Go') }}</button>\n\n                </fieldset>\n            </form>\n                <br>\n                <strong>{{ _('Keyboard:') }} </strong><a href=\"\" class=\"pure-button pure-button-primary\" id=\"btn-previous\">\n                &larr; {{ _('Previous') }}</a> &nbsp; <a class=\"pure-button pure-button-primary\" id=\"btn-next\" href=\"\">\n                &rarr; {{ _('Next') }}</a>\n        </div>\n    {% endif %}\n\n    <div class=\"tabs\">\n        <ul>\n            {% if last_error_text %}\n                <li class=\"tab\" id=\"error-text-tab\"><a href=\"#error-text\">{{ _('Error Text') }}</a></li> {% endif %}\n            {% if last_error_screenshot %}\n                <li class=\"tab\" id=\"error-screenshot-tab\"><a href=\"#error-screenshot\">{{ _('Error Screenshot') }}</a>\n                </li> {% endif %}\n            {% if history_n > 0 %}\n                <li class=\"tab\" id=\"text-tab\"><a href=\"#text\">{{ _('Text') }}</a></li>\n                <li class=\"tab\" id=\"screenshot-tab\"><a href=\"#screenshot\">{{ _('Current screenshot') }}</a></li>\n            {% endif %}\n        </ul>\n    </div>\n\n\n    <div id=\"diff-ui\">\n        <div class=\"tab-pane-inner\" id=\"error-text\">\n            <div class=\"snapshot-age error\">{{ watch.error_text_ctime|format_seconds_ago }} {{ _('seconds ago') }}</div>\n            <pre>\n            {{ last_error_text }}\n        </pre>\n        </div>\n\n        <div class=\"tab-pane-inner\" id=\"error-screenshot\">\n            <div class=\"snapshot-age error\">{{ watch.snapshot_error_screenshot_ctime|format_seconds_ago }} {{ _('seconds ago') }}\n            </div>\n            <img id=\"error-screenshot-img\" style=\"max-width: 80%\"\n                 alt=\"{{ _('Current erroring screenshot from most recent request') }}\">\n        </div>\n\n        <div class=\"tab-pane-inner\" id=\"text\">\n            {{ highlight_trigger_ignored_explainer() }}\n            <div class=\"snapshot-age\">{{ current_version|format_timestamp_timeago }}</div>\n            <pre id=\"difference\" style=\"border-left: 2px solid #ddd;\">{{ content| diff_unescape_difference_spans }}</pre>\n        </div>\n\n        <div class=\"tab-pane-inner\" id=\"screenshot\">\n            <div class=\"tip\">\n                {{ _('For now, Differences are performed on text, not graphically, only the latest screenshot is available.') }}\n            </div>\n            <br>\n            {% if capabilities.supports_screenshots %}\n                {% if screenshot %}\n                    <div class=\"snapshot-age\">{{ watch.snapshot_screenshot_ctime|format_timestamp_timeago }}</div>\n                    <img style=\"max-width: 80%\" id=\"screenshot-img\" alt=\"{{ _('Current screenshot from most recent request') }}\">\n                {% else %}\n                    {{ _('No screenshot available just yet! Try rechecking the page.') }}\n                {% endif %}\n            {% else %}\n                <strong>{{ _('Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.') }}</strong>\n            {% endif %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/blueprint/ui/views.py",
    "content": "from flask import Blueprint, request, redirect, url_for, flash\nfrom flask_babel import gettext\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.auth_decorator import login_optionally_required\nfrom changedetectionio import worker_pool\n\n\ndef construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_update):\n    views_blueprint = Blueprint('ui_views', __name__, template_folder=\"../ui/templates\")\n\n    @views_blueprint.route(\"/form/add/quickwatch\", methods=['POST'])\n    @login_optionally_required\n    def form_quick_watch_add():\n        from changedetectionio import forms\n        form = forms.quickWatchForm(request.form)\n\n        if not form.validate():\n            for widget, l in form.errors.items():\n                flash(','.join(l), 'error')\n            return redirect(url_for('watchlist.index'))\n\n        url = request.form.get('url').strip()\n        if datastore.url_exists(url):\n            flash(gettext('Warning, URL {} already exists').format(url), \"notice\")\n\n        add_paused = request.form.get('edit_and_watch_submit_button') != None\n        from changedetectionio import processors\n        processor = request.form.get('processor', processors.get_default_processor())\n        new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags','').strip(), extras={'paused': add_paused, 'processor': processor})\n\n        if new_uuid:\n            if add_paused:\n                flash(gettext('Watch added in Paused state, saving will unpause.'))\n                return redirect(url_for('ui.ui_edit.edit_page', uuid=new_uuid, unpause_on_save=1, tag=request.args.get('tag')))\n            else:\n                # Straight into the queue.\n                worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))\n                flash(gettext(\"Watch added.\"))\n\n        return redirect(url_for('watchlist.index', tag=request.args.get('tag','')))\n\n    return views_blueprint\n"
  },
  {
    "path": "changedetectionio/blueprint/watchlist/__init__.py",
    "content": "import os\nimport time\n\nfrom flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session\nfrom flask_paginate import Pagination, get_page_parameter\nfrom flask_babel import gettext as _\n\nfrom changedetectionio import forms\nfrom changedetectionio import processors\nfrom changedetectionio.store import ChangeDetectionStore\nfrom changedetectionio.auth_decorator import login_optionally_required\n\ndef construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):\n    watchlist_blueprint = Blueprint('watchlist', __name__, template_folder=\"templates\")\n    \n    @watchlist_blueprint.route(\"/\", methods=['GET'])\n    @login_optionally_required\n    def index():\n        active_tag_req = request.args.get('tag', '').lower().strip()\n        active_tag_uuid = active_tag = None\n\n        # Be sure limit_tag is a uuid\n        if active_tag_req:\n            for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():\n                if active_tag_req == tag.get('title', '').lower().strip() or active_tag_req == uuid:\n                    active_tag = tag\n                    active_tag_uuid = uuid\n                    break\n\n        # Redirect for the old rss path which used the /?rss=true\n        if request.args.get('rss'):\n            return redirect(url_for('rss.feed', tag=active_tag_uuid))\n\n        op = request.args.get('op')\n        if op:\n            uuid = request.args.get('uuid')\n            if op == 'pause':\n                datastore.data['watching'][uuid].toggle_pause()\n            elif op == 'mute':\n                datastore.data['watching'][uuid].toggle_mute()\n\n            datastore.data['watching'][uuid].commit()\n            return redirect(url_for('watchlist.index', tag = active_tag_uuid))\n\n        # Sort by last_changed and add the uuid which is usually the key..\n        sorted_watches = []\n        with_errors = request.args.get('with_errors') == \"1\"\n        unread_only = request.args.get('unread') == \"1\"\n        errored_count = 0\n        search_q = request.args.get('q').strip().lower() if request.args.get('q') else False\n        for uuid, watch in datastore.data['watching'].items():\n            if with_errors and not watch.get('last_error'):\n                continue\n\n            if unread_only and (watch.viewed or watch.last_changed == 0) :\n                continue\n\n            if active_tag_uuid and not active_tag_uuid in watch['tags']:\n                    continue\n            if watch.get('last_error'):\n                errored_count += 1\n\n            if search_q:\n                if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():\n                    sorted_watches.append(watch)\n                elif watch.get('last_error') and search_q in watch.get('last_error').lower():\n                    sorted_watches.append(watch)\n            else:\n                sorted_watches.append(watch)\n\n        form = forms.quickWatchForm(request.form)\n        page = request.args.get(get_page_parameter(), type=int, default=1)\n        total_count = len(sorted_watches)\n\n        pagination = Pagination(page=page,\n                                total=total_count,\n                                per_page=datastore.data['settings']['application'].get('pager_size', 50),\n                                css_framework=\"semantic\",\n                                display_msg=_('displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>'),\n                                record_name=_('records'))\n\n        sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])\n\n        proxy_list = datastore.proxy_list\n        output = render_template(\n            \"watch-overview.html\",\n            active_tag=active_tag,\n            active_tag_uuid=active_tag_uuid,\n            app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),\n            datastore=datastore,\n            errored_count=errored_count,\n            extra_classes='has-queue' if not update_q.empty() else '',\n            form=form,\n            generate_tag_colors=processors.generate_processor_badge_colors,\n            guid=datastore.data['app_guid'],\n            has_proxies=proxy_list,\n            hosted_sticky=os.getenv(\"SALTED_PASS\", False) == False,\n            now_time_server=round(time.time()),\n            pagination=pagination,\n            processor_badge_css=processors.get_processor_badge_css(),\n            processor_badge_texts=processors.get_processor_badge_texts(),\n            processor_descriptions=processors.get_processor_descriptions(),\n            queue_size=update_q.qsize(),\n            queued_uuids=update_q.get_queued_uuids(),\n            search_q=request.args.get('q', '').strip(),\n            sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),\n            sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),\n            system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),\n            tags=sorted_tags,\n            unread_changes_count=datastore.unread_changes_count,\n            watches=sorted_watches\n        )\n\n        # Return freed template-building memory to the OS immediately.\n        # render_template allocates ~20MB of intermediate strings that are freed on return,\n        # but glibc keeps those pages mapped in its arenas as RSS. malloc_trim() forces\n        # glibc to release them, preventing RSS growth from concurrent Chrome connections.\n        try:\n            import ctypes\n            ctypes.CDLL('libc.so.6').malloc_trim(0)\n        except Exception:\n            pass\n\n        if session.get('share-link'):\n            del (session['share-link'])\n\n        resp = make_response(output)\n\n        # The template can run on cookie or url query info\n        if request.args.get('sort'):\n            resp.set_cookie('sort', request.args.get('sort'))\n        if request.args.get('order'):\n            resp.set_cookie('order', request.args.get('order'))\n\n        return resp\n        \n    return watchlist_blueprint"
  },
  {
    "path": "changedetectionio/blueprint/watchlist/templates/watch-overview.html",
    "content": "{%- extends 'base.html' -%}\n{%- block content -%}\n{%- set tips = [\n    _(\"Changedetection.io can monitor more than just web-pages! See our plugins!\") ~ ' <a href=\"https://changedetection.io/plugins\">' ~ _('More info') ~ '</a>',\n    _(\"You can also add 'shared' watches.\") ~ ' <a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Sharing-a-Watch\">' ~ _('More info') ~ '</a>'\n] -%}\n{%- from '_helpers.html' import render_simple_field, render_field, render_nolabel_field, sort_by_title -%}\n<script src=\"{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}\"></script>\n<script src=\"{{url_for('static_content', group='js', filename='watch-overview.js')}}\" defer></script>\n<script src=\"{{url_for('static_content', group='js', filename='modal.js')}}\"></script>\n<script>let nowtimeserver={{ now_time_server }};</script>\n<script>let favicon_baseURL=\"{{ url_for('static_content', group='favicon', filename=\"PLACEHOLDER\")}}\";</script>\n<script>\n// Initialize Feather icons after the page loads\ndocument.addEventListener('DOMContentLoaded', function() {\n    feather.replace();\n\n    // Intersection Observer for lazy loading favicons\n    // Only load favicon images when they enter the viewport\n    if ('IntersectionObserver' in window) {\n        const faviconObserver = new IntersectionObserver((entries, observer) => {\n            entries.forEach(entry => {\n                if (entry.isIntersecting) {\n                    const img = entry.target;\n                    const src = img.getAttribute('data-src');\n\n                    if (src) {\n                        // Load the actual favicon\n                        img.src = src;\n                        img.removeAttribute('data-src');\n                    }\n\n                    // Stop observing this image\n                    observer.unobserve(img);\n                }\n            });\n        }, {\n            // Start loading slightly before the image enters viewport\n            rootMargin: '50px',\n            threshold: 0.01\n        });\n\n        // Observe all lazy favicon images\n        document.querySelectorAll('.lazy-favicon').forEach(img => {\n            faviconObserver.observe(img);\n        });\n    } else {\n        // Fallback for older browsers: load all favicons immediately\n        document.querySelectorAll('.lazy-favicon').forEach(img => {\n            const src = img.getAttribute('data-src');\n            if (src) {\n                img.src = src;\n                img.removeAttribute('data-src');\n            }\n        });\n    }\n});\n</script>\n<style>\n.checking-now .last-checked {\n    background-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.05) 40%, rgba(0,0,0,0.1) 100%);\n    background-size: 0 100%;\n    background-repeat: no-repeat;\n    transition: background-size 0.9s ease\n}\n\n/* Auto-generated processor badge colors */\n{{ processor_badge_css|safe }}\n\n/* Auto-generated tag colors */\n{%- for uuid, tag in tags -%}\n{%- if tag and tag.title -%}\n{%- set class_name = tag.title|sanitize_tag_class -%}\n{%- set colors = generate_tag_colors(tag.title) -%}\n.button-tag.tag-{{ class_name }} {\n  background-color: {{ colors['light']['bg'] }};\n  color: {{ colors['light']['color'] }};\n}\n\n.watch-tag-list.tag-{{ class_name }} {\n  background-color: {{ colors['light']['bg'] }};\n  color: {{ colors['light']['color'] }};\n}\n\nhtml[data-darkmode=\"true\"] .button-tag.tag-{{ class_name }} {\n  background-color: {{ colors['dark']['bg'] }};\n  color: {{ colors['dark']['color'] }};\n}\n\nhtml[data-darkmode=\"true\"] .watch-tag-list.tag-{{ class_name }} {\n  background-color: {{ colors['dark']['bg'] }};\n  color: {{ colors['dark']['color'] }};\n}\n{%- endif -%}\n{%- endfor -%}\n</style>\n<div class=\"box\" id=\"form-quick-watch-add\">\n\n    <form class=\"pure-form\" action=\"{{ url_for('ui.ui_views.form_quick_watch_add', tag=active_tag_uuid) }}\" method=\"POST\" id=\"new-watch-form\">\n        <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\" >\n        <fieldset>\n            <legend>{{ _('Add a new web page change detection watch') }}</legend>\n            <div id=\"watch-add-wrapper-zone\">\n                    {{ render_nolabel_field(form.url, placeholder=\"https://...\", required=true) }}\n                    {{ render_nolabel_field(form.watch_submit_button, title=_(\"Watch this URL!\") ) }}\n                    {{ render_nolabel_field(form.edit_and_watch_submit_button, title=_(\"Edit first then Watch\") ) }}\n            </div>\n            <div id=\"watch-group-tag\">\n               {{ render_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder=_(\"Watch group / tag\"), class=\"transparent-field\") }}\n            </div>\n            <div id=\"quick-watch-processor-type\">\n                {{ render_simple_field(form.processor) }}\n            </div>\n\n        </fieldset>\n        <span style=\"color:#eee; font-size: 80%;\">\n            <strong>Tip: </strong> {{ tips | random | safe }}\n        </span>\n    </form>\n</div>\n<div class=\"box\">\n    <form class=\"pure-form\" action=\"{{ url_for('ui.form_watch_list_checkbox_operations') }}\" method=\"POST\" id=\"watch-list-form\">\n    <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\" >\n    <input type=\"hidden\" id=\"op_extradata\" name=\"op_extradata\" value=\"\" >\n    <div id=\"checkbox-operations\">\n        <button class=\"pure-button button-secondary button-xsmall\"  name=\"op\" value=\"pause\"><i data-feather=\"pause\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Pause') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\"  name=\"op\" value=\"unpause\"><i data-feather=\"play\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('UnPause') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\"  name=\"op\" value=\"mute\"><i data-feather=\"volume-x\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Mute') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\"  name=\"op\" value=\"unmute\"><i data-feather=\"volume-2\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('UnMute') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\" name=\"op\" value=\"recheck\"><i data-feather=\"refresh-cw\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Recheck') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\" name=\"op\" value=\"assign-tag\" id=\"checkbox-assign-tag\"><i data-feather=\"tag\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Tag') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\" name=\"op\" value=\"mark-viewed\"><i data-feather=\"eye\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Mark viewed') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\" name=\"op\" value=\"notification-default\"><i data-feather=\"bell\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Use default notification') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\" name=\"op\" value=\"clear-errors\"><i data-feather=\"x-circle\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Clear errors') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\" style=\"background: #dd4242;\" name=\"op\" value=\"clear-history\"\n                       data-requires-confirm\n                       data-confirm-type=\"danger\"\n                       data-confirm-title=\"{{ _('Clear Histories') }}\"\n                       data-confirm-message=\"{{ _('<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>') }}\"\n                       data-confirm-button=\"{{ _('OK') }}\"><i data-feather=\"trash-2\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Clear/reset history') }}</button>\n        <button class=\"pure-button button-secondary button-xsmall\" style=\"background: #dd4242;\" name=\"op\" value=\"delete\"\n                       data-requires-confirm\n                       data-confirm-type=\"danger\"\n                       data-confirm-title=\"{{ _('Delete Watches?') }}\"\n                       data-confirm-message=\"{{ _('<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}\"\n                       data-confirm-button=\"{{ _('Delete') }}\"><i data-feather=\"trash\" style=\"width: 14px; height: 14px; stroke: white; margin-right: 4px;\"></i>{{ _('Delete') }}</button>\n    </div>\n\n    <div id=\"stats_row\">\n        <div class=\"left\">{%- if watches|length >= pagination.per_page -%}{{ pagination.info }}{%- endif -%}</div>\n        <div class=\"right\" >{{ _('Queued size') }}: <span id=\"queue-size-int\">{{ queue_size }}</span></div>\n    </div>\n\n\n\n    {%- if search_q -%}<div id=\"search-result-info\">{{ _('Searching') }} \"<strong><i>{{search_q}}</i></strong>\"</div>{%- endif -%}\n    <div>\n        <a href=\"{{url_for('watchlist.index')}}\" class=\"pure-button button-tag {{'active' if not active_tag_uuid }}\">{{ _('All') }}</a>\n\n    <!-- tag list -->\n    {%- for uuid, tag in tags -%}\n        {%- if tag != \"\" -%}\n            <a href=\"{{url_for('watchlist.index', tag=uuid) }}\" class=\"pure-button button-tag tag-{{ tag.title|sanitize_tag_class }} {{'active' if active_tag_uuid == uuid }}\">{{ tag.title }}</a>\n        {%- endif -%}\n    {%- endfor -%}\n    </div>\n\n    {%- set sort_order = sort_order or 'asc' -%}\n    {%- set sort_attribute = sort_attribute or 'last_changed'  -%}\n    {%- set pagination_page = request.args.get('page', 0) -%}\n    {%- set cols_required = 6 -%}\n    {%- set any_has_restock_price_processor = datastore.any_watches_have_processor_by_name(\"restock_diff\") -%}\n    {%- if any_has_restock_price_processor -%}\n        {%- set cols_required = cols_required + 1 -%}\n    {%- endif -%}\n    {%- set ui_settings = datastore.data['settings']['application']['ui'] -%}\n    {%- set wrapper_classes = [\n        'has-unread-changes' if unread_changes_count else '',\n        'has-error' if errored_count else '',\n    ] -%}\n    <div id=\"watch-table-wrapper\" class=\"{{ wrapper_classes | reject('equalto', '') | join(' ') }}\">\n        {%- set table_classes = [\n            'favicon-enabled' if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] else 'favicon-not-enabled',\n        ] -%}\n        <table class=\"pure-table pure-table-striped watch-table {{ table_classes | reject('equalto', '') | join(' ') }}\">\n            <thead>\n            <tr>\n                {%- set link_order = \"desc\" if sort_order  == 'asc' else \"asc\" -%}\n                {%- set arrow_span = \"\" -%}\n                <th><input style=\"vertical-align: middle\" type=\"checkbox\" id=\"check-all\" > <a class=\"{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}\"  href=\"{{url_for('watchlist.index', sort='date_created', order=link_order, tag=active_tag_uuid)}}\"># <span class='arrow {{link_order}}'></span></a></th>\n                <th>\n                    <a class=\"{{ 'active '+link_order if sort_attribute == 'paused' else 'inactive' }}\" href=\"{{url_for('watchlist.index', sort='paused', order=link_order, tag=active_tag_uuid)}}\"><i data-feather=\"pause\" style=\"vertical-align: bottom; width: 14px; height: 14px;  margin-right: 4px;\"></i><span class='arrow {{link_order}}'></span></a>\n                    &nbsp;\n                    <a class=\"{{ 'active '+link_order if sort_attribute == 'notification_muted' else 'inactive' }}\" href=\"{{url_for('watchlist.index', sort='notification_muted', order=link_order, tag=active_tag_uuid)}}\"><i data-feather=\"volume-2\" style=\"vertical-align: bottom; width: 14px; height: 14px;  margin-right: 4px;\"></i><span class='arrow {{link_order}}'></span></a>\n                </th>\n                <th><a class=\"{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}\" href=\"{{url_for('watchlist.index', sort='label', order=link_order, tag=active_tag_uuid)}}\">{{ _('Website') }} <span class='arrow {{link_order}}'></span></a></th>\n             {%- if any_has_restock_price_processor -%}\n                <th>{{ _('Restock & Price') }}</th>\n             {%- endif -%}\n                <th><a class=\"{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}\" href=\"{{url_for('watchlist.index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}\"><span class=\"hide-on-mobile\">{{ _('Last') }}</span> {{ _('Checked') }} <span class='arrow {{link_order}}'></span></a></th>\n                <th><a class=\"{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}\" href=\"{{url_for('watchlist.index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}\"><span class=\"hide-on-mobile\">{{ _('Last') }}</span> {{ _('Changed') }} <span class='arrow {{link_order}}'></span></a></th>\n                <th class=\"empty-cell\"></th>\n            </tr>\n            </thead>\n            <tbody>\n            {%- if not watches|length -%}\n            <tr>\n                <td colspan=\"{{ cols_required }}\" style=\"text-wrap: wrap;\">{{ _('No web page change detection watches configured, please add a URL in the box above, or') }} <a href=\"{{ url_for('imports.import_page')}}\" >{{ _('import a list') }}</a>.</td>\n            </tr>\n            {%- endif -%}\n\n            {%- for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) -%}\n                {%- set checking_now = is_checking_now(watch) -%}\n                {%- set history_n = watch.history_n -%}\n                {%- set favicon = watch.get_favicon_filename() -%}\n                {%- set error_texts = watch.compile_error_texts(has_proxies=has_proxies) -%}\n                {%- set system_use_url_watchlist = datastore.data['settings']['application']['ui'].get('use_page_title_in_list')  -%}\n                {#  Class settings mirrored in changedetectionio/static/js/realtime.js for the frontend #}\n                {%- set row_classes = [\n                    loop.cycle('pure-table-odd', 'pure-table-even'),\n                    'processor-' ~ watch['processor'],\n                    'has-error' if error_texts|length > 2 else '',\n                    'paused' if watch.paused is defined and watch.paused != False else '',\n                    'unviewed' if watch.has_unviewed else '',\n                    'has-restock-info' if watch.has_restock_info else 'no-restock-info',\n                    'has-favicon' if favicon else '',\n                    'in-stock' if watch.has_restock_info and watch['restock']['in_stock'] else '',\n                    'not-in-stock' if watch.has_restock_info and not watch['restock']['in_stock'] else '',\n                    'queued' if watch.uuid in queued_uuids else '',\n                    'checking-now' if checking_now else '',\n                    'notification_muted' if watch.notification_muted else '',\n                    'single-history' if history_n == 1 else '',\n                    'multiple-history' if history_n >= 2 else '',\n                    'use-html-title' if system_use_url_watchlist else 'no-html-title',\n                ] -%}\n            <tr id=\"{{ watch.uuid }}\" data-watch-uuid=\"{{ watch.uuid }}\" class=\"{{ row_classes | reject('equalto', '') | join(' ') }}\">\n                <td class=\"inline checkbox-uuid\" ><div><input name=\"uuids\"  type=\"checkbox\" value=\"{{ watch.uuid}} \" > <span class=\"counter-i\">{{ loop.index+pagination.skip }}</span></div></td>\n                <td class=\"inline watch-controls\">\n                    <div>\n                    <a class=\"ajax-op state-off pause-toggle\" data-op=\"pause\" href=\"{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}\"><img src=\"{{url_for('static_content', group='images', filename='pause.svg')}}\" alt=\"Pause checks\" title=\"Pause checks\" class=\"icon icon-pause\" ></a>\n                    <a class=\"ajax-op state-on pause-toggle\"  data-op=\"pause\" style=\"display: none\" href=\"{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}\"><img src=\"{{url_for('static_content', group='images', filename='play.svg')}}\" alt=\"UnPause checks\" title=\"UnPause checks\" class=\"icon icon-unpause\" ></a>\n                    <a class=\"ajax-op state-off mute-toggle\" data-op=\"mute\" href=\"{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}\"><img src=\"{{url_for('static_content', group='images', filename='bell-off.svg')}}\" alt=\"Mute notification\" title=\"Mute notification\" class=\"icon icon-mute\" ></a>\n                    <a class=\"ajax-op state-on mute-toggle\" data-op=\"mute\"  style=\"display: none\" href=\"{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}\"><img src=\"{{url_for('static_content', group='images', filename='bell-off.svg')}}\" alt=\"UnMute notification\" title=\"UnMute notification\" class=\"icon icon-mute\" ></a>\n                    </div>\n                </td>\n\n                <td class=\"title-col inline\">\n                    <div class=\"flex-wrapper\">\n                    {% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}\n                        <div>\n                            {# Intersection Observer lazy loading: store real URL in data-src, load only when visible in viewport #}\n                            <img alt=\"Favicon thumbnail\"\n                                 class=\"favicon lazy-favicon\"\n                                 loading=\"lazy\"\n                                 decoding=\"async\"\n                                 fetchpriority=\"low\"\n                                 {% if favicon %}\n                                 data-src=\"{{url_for('static_content', group='favicon', filename=watch.uuid)}}\"\n                                 {% endif %}\n                                 src='data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"7.087\" height=\"7.087\" viewBox=\"0 0 7.087 7.087\"%3E%3Ccircle cx=\"3.543\" cy=\"3.543\" r=\"3.279\" stroke=\"%23e1e1e1\" stroke-width=\"0.45\" fill=\"none\" opacity=\"0.74\"/%3E%3C/svg%3E'>\n                        </div>\n                    {%  endif %}\n                        <div>\n                            {%- if watch['processor'] and watch['processor'] in processor_badge_texts -%}\n                                <span class=\"processor-badge processor-badge-{{ watch['processor'] }}\" title=\"{{ processor_descriptions.get(watch['processor'], watch['processor']) }}\">{{ processor_badge_texts[watch['processor']] }}</span>\n                            {%- endif -%}\n                            <span class=\"watch-title\">\n                                {% if system_use_url_watchlist or watch.get('use_page_title_in_list') %}\n                                    {{ watch.label }}\n                                {% else %}\n                                    {{ watch.get('title') or watch.link }}\n                                {% endif %}\n                               <a class=\"external\" target=\"_blank\" rel=\"noopener\" href=\"{{ watch.link.replace('source:','') }}\">&nbsp;</a>\n                            </span>\n                            <div class=\"error-text\" style=\"display:none;\">{{ error_texts|safe }}</div>\n                            {%- if watch['processor'] == 'text_json_diff'  -%}\n                                {%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data']  -%}\n                                <div class=\"ldjson-price-track-offer\">Switch to Restock & Price watch mode? <a href=\"{{url_for('price_data_follower.accept', uuid=watch.uuid)}}\" class=\"pure-button button-xsmall\">Yes</a> <a href=\"{{url_for('price_data_follower.reject', uuid=watch.uuid)}}\" class=\"\">No</a></div>\n                                {%- endif -%}\n                            {%- endif -%}\n\n                            {%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}\n                              <a href=\"{{url_for('watchlist.index', tag=watch_tag_uuid) }}\" class=\"watch-tag-list tag-{{ watch_tag.title|sanitize_tag_class }}\">{{ watch_tag.title }}</a>\n                            {%- endfor -%}\n                        </div>\n                    <div class=\"status-icons\">\n                            <a class=\"link-spread\" href=\"{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}\"><img src=\"{{url_for('static_content', group='images', filename='spread.svg')}}\" class=\"status-icon icon icon-spread\" title=\"Create a link to share watch config with others\" ></a>\n                            {%- set effective_fetcher = watch.get_fetch_backend if watch.get_fetch_backend != \"system\" else system_default_fetcher -%}\n                            {%- if effective_fetcher and (\"html_webdriver\" in effective_fetcher or \"html_\" in effective_fetcher or \"extra_browser_\" in effective_fetcher) -%}\n                                {{ effective_fetcher|fetcher_status_icons }}\n                            {%- endif -%}\n                            {%- if watch.is_pdf  -%}<img class=\"status-icon\" src=\"{{url_for('static_content', group='images', filename='pdf-icon.svg')}}\" alt=\"Converting PDF to text\" >{%- endif -%}\n                            {%- if watch.has_browser_steps -%}<img class=\"status-icon status-browsersteps\" src=\"{{url_for('static_content', group='images', filename='steps.svg')}}\" alt=\"Browser Steps is enabled\" >{%- endif -%}\n\n                    </div>\n                    </div>\n                </td>\n{%- if any_has_restock_price_processor -%}\n                <td class=\"restock-and-price\">\n                    {%- if watch['processor'] == 'restock_diff'  -%}\n                        {%- if watch.has_restock_info -%}\n                            <span class=\"restock-label {{'in-stock' if watch['restock']['in_stock'] else 'not-in-stock' }}\" title=\"{{ _('Detecting restock and price') }}\">\n                                <!-- maybe some object watch['processor'][restock_diff] or.. -->\n                                 {%- if watch['restock']['in_stock']-%}  {{ _('In stock') }} {%- else-%}  {{ _('Not in stock') }} {%- endif -%}\n                            </span>\n                        {%- endif -%}\n\n                        {%- if watch.get('restock') and watch['restock'].get('price') -%}\n                            {%- set restock = watch['restock'] -%}\n                            {%- set price = restock.get('price') -%}\n                            {%- set cur = restock.get('currency','') -%}\n\n                            {%- if price is not none and (price|string)|regex_search('\\d') -%}\n                              <span class=\"restock-label price\" title=\"{{ _('Price') }}\">\n                              {# @todo: make parse_currency/parse_decimal aware of the locale of the actual web page and use that instead changedetectionio/processors/restock_diff/__init__.py #}\n                                {%- if price is number -%}{# It's a number so we can convert it to their locale' #}\n                                  {{ price|format_number_locale }} {{ cur }}<!-- as number -->\n                                {%- else -%}{# It's totally fine if it arrives as something else, the website might be something weird in this field #}\n                                  {{ price }} {{ cur }}<!-- as string -->\n                                {%- endif -%}\n                              </span>\n                            {%- endif -%}\n                        {%- elif not watch.has_restock_info -%}\n                            <span class=\"restock-label error\">{{ _('No information') }}</span>\n                        {%- endif -%}\n                    {%- endif -%}\n                </td>\n{%- endif -%}\n            {#last_checked becomes fetch-start-time#}\n                <td class=\"last-checked\" data-timestamp=\"{{ watch.last_checked }}\" data-fetchduration={{ watch.fetch_time }} data-eta_complete=\"{{ watch.last_checked+watch.fetch_time }}\" >\n                    <div class=\"spinner-wrapper\" style=\"display:none;\" >\n                        <span class=\"spinner\"></span><span class=\"status-text\">&nbsp;{{ _('Checking now') }}</span>\n                    </div>\n                    <span class=\"innertext\">{{watch|format_last_checked_time|safe}}</span>\n                </td>\n                <td class=\"last-changed\" data-timestamp=\"{{ watch.last_changed }}\">{%- if watch.history_n >=2 and watch.last_changed >0 -%}\n                    {{watch.last_changed|format_timestamp_timeago}}\n                    {%- else -%}\n                    {{ _('Not yet') }}\n                    {%- endif -%}\n                </td>\n                <td class=\"buttons\">\n                    <div>\n                    {%- set target_attr = ' target=\"' ~ watch.uuid ~ '\"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%}\n                    <a href=\"\" class=\"already-in-queue-button recheck pure-button pure-button-primary\" style=\"display: none;\" disabled=\"disabled\">{{ _('Queued') }}</a>\n                    <a href=\"{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}\" data-op='recheck' class=\"ajax-op recheck pure-button pure-button-primary\">{{ _('Recheck') }}</a>\n                    <a href=\"{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general\" class=\"pure-button pure-button-primary\">{{ _('Edit') }}</a>\n                    <a href=\"{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}\" {{target_attr}} class=\"pure-button pure-button-primary history-link\" style=\"display: none;\">{{ _('History') }}</a>\n                    <a href=\"{{ url_for('ui.ui_preview.preview_page', uuid=watch.uuid)}}\" {{target_attr}} class=\"pure-button pure-button-primary preview-link\" style=\"display: none;\">{{ _('Preview') }}</a>\n                    </div>\n                </td>\n            </tr>\n            {%- endfor -%}\n            </tbody>\n        </table>\n        <ul id=\"post-list-buttons\">\n            <li id=\"post-list-with-errors\" style=\"display: none;\" >\n                <a href=\"{{url_for('watchlist.index', with_errors=1, tag=request.args.get('tag')) }}\" class=\"pure-button button-tag button-error\">{{ _('With errors') }} ({{ errored_count }})</a>\n            </li>\n            <li id=\"post-list-mark-views\" style=\"display: none;\" >\n                <a href=\"{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}\" class=\"pure-button button-tag \" id=\"mark-all-viewed\">{{ _('Mark all viewed') }}</a>\n            </li>\n        {%-  if active_tag_uuid -%}\n            <li id=\"post-list-mark-views-tag\">\n                <a href=\"{{url_for('ui.mark_all_viewed', tag=active_tag_uuid) }}\" class=\"pure-button button-tag \" id=\"mark-all-viewed\">{{ _(\"Mark all viewed in '%(title)s'\", title=active_tag.title) }}</a>\n            </li>\n        {%-  endif -%}\n            <li id=\"post-list-unread\" style=\"display: none;\" >\n                <a href=\"{{url_for('watchlist.index', unread=1, tag=request.args.get('tag')) }}\" class=\"pure-button button-tag\">{{ _('Unread') }} (<span id=\"unread-tab-counter\">{{ unread_changes_count }}</span>)</a>\n            </li>\n            <li>\n               <a href=\"{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}\" class=\"pure-button button-tag\" id=\"recheck-all\">{{ _('Recheck all') }} {% if active_tag_uuid %}  {{ _(\"in '%(title)s'\", title=active_tag.title) }}{%endif%}</a>\n            </li>\n            <li>\n                <a href=\"{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}\"><img alt=\"RSS Feed\" id=\"feed-icon\" src=\"{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}\" height=\"15\"></a>\n            </li>\n        </ul>\n        {{ pagination.links }}\n    </div>\n    </form>\n</div>\n{%- endblock -%}"
  },
  {
    "path": "changedetectionio/browser_steps/__init__.py",
    "content": ""
  },
  {
    "path": "changedetectionio/browser_steps/browser_steps.py",
    "content": "import os\nimport time\nimport re\nfrom random import randint\nfrom loguru import logger\n\nfrom changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT\nfrom changedetectionio.content_fetchers.base import manage_user_agent\nfrom changedetectionio.jinja2_custom import render as jinja_render\n\ndef browser_steps_get_valid_steps(browser_steps: list):\n    if browser_steps is not None and len(browser_steps):\n        valid_steps = list(filter(\n            lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one'),browser_steps))\n\n        # Just incase they selected Goto site by accident with older JS\n        if valid_steps and valid_steps[0]['operation'] == 'Goto site':\n            del(valid_steps[0])\n\n        return valid_steps\n    return []\n\n\n# Two flags, tell the JS which of the \"Selector\" or \"Value\" field should be enabled in the front end\n# 0- off, 1- on\nbrowser_step_ui_config = {'Choose one': '0 0',\n                          #                 'Check checkbox': '1 0',\n                          #                 'Click button containing text': '0 1',\n                          #                 'Scroll to bottom': '0 0',\n                          #                 'Scroll to element': '1 0',\n                          #                 'Scroll to top': '0 0',\n                          #                 'Switch to iFrame by index number': '0 1'\n                          #                 'Uncheck checkbox': '1 0',\n                          # @todo\n                          'Check checkbox': '1 0',\n                          'Click X,Y': '0 1',\n                          'Click element if exists': '1 0',\n                          'Click element': '1 0',\n                          'Click element containing text': '0 1',\n                          'Click element containing text if exists': '0 1',\n                          'Enter text in field': '1 1',\n                          'Execute JS': '0 1',\n#                          'Extract text and use as filter': '1 0',\n                          'Goto site': '0 0',\n                          'Goto URL': '0 1',\n                          'Make all child elements visible': '1 0',\n                          'Press Enter': '0 0',\n                          'Select by label': '1 1',\n                          '<select> by option text': '1 1',\n                          'Scroll down': '0 0',\n                          'Uncheck checkbox': '1 0',\n                          'Wait for seconds': '0 1',\n                          'Wait for text': '0 1',\n                          'Wait for text in element': '1 1',\n                          'Remove elements': '1 0',\n                          #                          'Press Page Down': '0 0',\n                          #                          'Press Page Up': '0 0',\n                          # weird bug, come back to it later\n                          }\n\n\n# Good reference - https://playwright.dev/python/docs/input\n#                  https://pythonmana.com/2021/12/202112162236307035.html\n#\n# ONLY Works in Playwright because we need the fullscreen screenshot\nclass steppable_browser_interface():\n    page = None\n    start_url = None\n    action_timeout = 10 * 1000\n\n    def __init__(self, start_url):\n        self.start_url = start_url\n\n    # Convert and perform \"Click Button\" for example\n    async def call_action(self, action_name, selector=None, optional_value=None):\n        if self.page is None:\n            logger.warning(\"Cannot call action on None page object\")\n            return\n            \n        now = time.time()\n        call_action_name = re.sub('[^0-9a-zA-Z]+', '_', action_name.lower())\n        if call_action_name == 'choose_one':\n            return\n\n        logger.debug(f\"> Action calling '{call_action_name}'\")\n        # https://playwright.dev/python/docs/selectors#xpath-selectors\n        if selector and selector.startswith('/') and not selector.startswith('//'):\n            selector = \"xpath=\" + selector\n\n        # Check if action handler exists\n        if not hasattr(self, \"action_\" + call_action_name):\n            logger.warning(f\"Action handler for '{call_action_name}' not found\")\n            return\n            \n        action_handler = getattr(self, \"action_\" + call_action_name)\n\n        # Support for Jinja2 variables in the value and selector\n        if selector and ('{%' in selector or '{{' in selector):\n            selector = jinja_render(template_str=selector)\n\n        if optional_value and ('{%' in optional_value or '{{' in optional_value):\n            optional_value = jinja_render(template_str=optional_value)\n\n        # Trigger click and cautiously handle potential navigation\n        # This means the page redirects/reloads/changes JS etc etc\n        if call_action_name.startswith('click_'):\n            try:\n                # Set up navigation expectation before the click (like sync version)\n                async with self.page.expect_event(\"framenavigated\", timeout=3000) as navigation_info:\n                    await action_handler(selector, optional_value)\n                \n                # Check if navigation actually occurred\n                try:\n                    await navigation_info.value  # This waits for the navigation promise\n                    logger.debug(f\"Navigation occurred on {call_action_name}.\")\n                except Exception:\n                    logger.debug(f\"No navigation occurred within timeout when calling {call_action_name}, that's OK, continuing.\")\n                    \n            except Exception as e:\n                # If expect_event itself times out, that means no navigation occurred - that's OK\n                if \"framenavigated\" in str(e) and \"exceeded\" in str(e):\n                    logger.debug(f\"No navigation occurred within timeout when calling {call_action_name}, that's OK, continuing.\")\n                else:\n                    raise e\n        else:\n            # Some other action that probably a navigation is not expected\n            await action_handler(selector, optional_value)\n\n\n        # Safely wait for timeout\n        await self.page.wait_for_timeout(1.5 * 1000)\n        logger.debug(f\"Call action done in {time.time()-now:.2f}s\")\n\n    async def action_goto_url(self, selector=None, value=None):\n        if not value:\n            logger.warning(\"No URL provided for goto_url action\")\n            return None\n            \n        now = time.time()\n        response = await self.page.goto(value, timeout=0, wait_until='load')\n        logger.debug(f\"Time to goto URL {time.time()-now:.2f}s\")\n        return response\n\n    # Incase they request to go back to the start\n    async def action_goto_site(self, selector=None, value=None):\n        return await self.action_goto_url(value=re.sub(r'^source:', '', self.start_url, flags=re.IGNORECASE))\n\n    async def action_click_element_containing_text(self, selector=None, value=''):\n        logger.debug(\"Clicking element containing text\")\n        if not value or not len(value.strip()):\n            return\n            \n        elem = self.page.get_by_text(value)\n        if await elem.count():\n            await elem.first.click(delay=randint(200, 500), timeout=self.action_timeout)\n\n\n    async def action_click_element_containing_text_if_exists(self, selector=None, value=''):\n        logger.debug(\"Clicking element containing text if exists\")\n        if not value or not len(value.strip()):\n            return\n            \n        elem = self.page.get_by_text(value)\n        count = await elem.count()\n        logger.debug(f\"Clicking element containing text - {count} elements found\")\n        if count:\n            await elem.first.click(delay=randint(200, 500), timeout=self.action_timeout)\n                \n\n    async def action_enter_text_in_field(self, selector, value):\n        if not selector or not len(selector.strip()):\n            return\n\n        await self.page.fill(selector, value, timeout=self.action_timeout)\n\n    async def action_execute_js(self, selector, value):\n        if not value:\n            return None\n            \n        return await self.page.evaluate(value)\n\n    async def action_click_element(self, selector, value):\n        logger.debug(\"Clicking element\")\n        if not selector or not len(selector.strip()):\n            return\n\n        await self.page.click(selector=selector, timeout=self.action_timeout + 20 * 1000, delay=randint(200, 500))\n\n    async def action_click_element_if_exists(self, selector, value):\n        import playwright._impl._errors as _api_types\n        logger.debug(\"Clicking element if exists\")\n        if not selector or not len(selector.strip()):\n            return\n            \n        try:\n            await self.page.click(selector, timeout=self.action_timeout, delay=randint(200, 500))\n        except _api_types.TimeoutError:\n            return\n        except _api_types.Error:\n            # Element was there, but page redrew and now its long long gone\n            return\n                \n\n    async def action_click_x_y(self, selector, value):\n        if not value or not re.match(r'^\\s?\\d+\\s?,\\s?\\d+\\s?$', value):\n            logger.warning(\"'Click X,Y' step should be in the format of '100 , 90'\")\n            return\n\n        try:\n            x, y = value.strip().split(',')\n            x = int(float(x.strip()))\n            y = int(float(y.strip()))\n            \n            await self.page.mouse.click(x=x, y=y, delay=randint(200, 500))\n                \n        except Exception as e:\n            logger.error(f\"Error parsing x,y coordinates: {str(e)}\")\n\n    async def action__select_by_option_text(self, selector, value):\n        if not selector or not len(selector.strip()):\n            return\n\n        await self.page.select_option(selector, label=value, timeout=self.action_timeout)\n\n    async def action_scroll_down(self, selector, value):\n        # Some sites this doesnt work on for some reason\n        await self.page.mouse.wheel(0, 600)\n        await self.page.wait_for_timeout(1000)\n\n    async def action_wait_for_seconds(self, selector, value):\n        try:\n            seconds = float(value.strip()) if value else 1.0\n            await self.page.wait_for_timeout(seconds * 1000)\n        except (ValueError, TypeError) as e:\n            logger.error(f\"Invalid value for wait_for_seconds: {str(e)}\")\n\n    async def action_wait_for_text(self, selector, value):\n        if not value:\n            return\n            \n        import json\n        v = json.dumps(value)\n        await self.page.wait_for_function(\n            f'document.querySelector(\"body\").innerText.includes({v});',\n            timeout=30000\n        )\n            \n\n    async def action_wait_for_text_in_element(self, selector, value):\n        if not selector or not value:\n            return\n            \n        import json\n        s = json.dumps(selector)\n        v = json.dumps(value)\n        \n        await self.page.wait_for_function(\n            f'document.querySelector({s}).innerText.includes({v});',\n            timeout=30000\n        )\n\n    # @todo - in the future make some popout interface to capture what needs to be set\n    # https://playwright.dev/python/docs/api/class-keyboard\n    async def action_press_enter(self, selector, value):\n        await self.page.keyboard.press(\"Enter\", delay=randint(200, 500))\n            \n\n    async def action_press_page_up(self, selector, value):\n        await self.page.keyboard.press(\"PageUp\", delay=randint(200, 500))\n\n    async def action_press_page_down(self, selector, value):\n        await self.page.keyboard.press(\"PageDown\", delay=randint(200, 500))\n\n    async def action_check_checkbox(self, selector, value):\n        if not selector:\n            return\n\n        await self.page.locator(selector).check(timeout=self.action_timeout)\n\n    async def action_uncheck_checkbox(self, selector, value):\n        if not selector:\n            return\n            \n        await self.page.locator(selector).uncheck(timeout=self.action_timeout)\n            \n\n    async def action_remove_elements(self, selector, value):\n        \"\"\"Removes all elements matching the given selector from the DOM.\"\"\"\n        if not selector:\n            return\n            \n        await self.page.locator(selector).evaluate_all(\"els => els.forEach(el => el.remove())\")\n\n    async def action_make_all_child_elements_visible(self, selector, value):\n        \"\"\"Recursively makes all child elements inside the given selector fully visible.\"\"\"\n        if not selector:\n            return\n            \n        await self.page.locator(selector).locator(\"*\").evaluate_all(\"\"\"\n            els => els.forEach(el => {\n                el.style.display = 'block';   // Forces it to be displayed\n                el.style.visibility = 'visible';   // Ensures it's not hidden\n                el.style.opacity = '1';   // Fully opaque\n                el.style.position = 'relative';   // Avoids 'absolute' hiding\n                el.style.height = 'auto';   // Expands collapsed elements\n                el.style.width = 'auto';   // Ensures full visibility\n                el.removeAttribute('hidden');   // Removes hidden attribute\n                el.classList.remove('hidden', 'd-none');  // Removes common CSS hidden classes\n            })\n        \"\"\")\n\n# Responsible for maintaining a live 'context' with the chrome CDP\n# @todo - how long do contexts live for anyway?\nclass browsersteps_live_ui(steppable_browser_interface):\n    context = None\n    page = None\n    render_extra_delay = 1\n    stale = False\n    # bump and kill this if idle after X sec\n    age_start = 0\n    headers = {}\n    # Track if resources are properly cleaned up\n    _is_cleaned_up = False\n    \n    # use a special driver, maybe locally etc\n    command_executor = os.getenv(\n        \"PLAYWRIGHT_BROWSERSTEPS_DRIVER_URL\"\n    )\n    # if not..\n    if not command_executor:\n        command_executor = os.getenv(\n            \"PLAYWRIGHT_DRIVER_URL\",\n            'ws://playwright-chrome:3000'\n        ).strip('\"')\n\n    browser_type = os.getenv(\"PLAYWRIGHT_BROWSER_TYPE\", 'chromium').strip('\"')\n\n    def __init__(self, playwright_browser, proxy=None, headers=None, start_url=None):\n        self.headers = headers or {}\n        self.age_start = time.time()\n        self.playwright_browser = playwright_browser\n        self.start_url = start_url\n        self._is_cleaned_up = False\n        self.proxy = proxy\n        # Note: connect() is now async and must be called separately\n\n    def __del__(self):\n        # Ensure cleanup happens if object is garbage collected\n        # Note: cleanup is now async, so we can only mark as cleaned up here\n        self._is_cleaned_up = True\n\n    # Connect and setup a new context\n    async def connect(self, proxy=None):\n        # Should only get called once - test that\n        keep_open = 1000 * 60 * 5\n        now = time.time()\n\n        # @todo handle multiple contexts, bind a unique id from the browser on each req?\n        self.context = await self.playwright_browser.new_context(\n            accept_downloads=False,  # Should never be needed\n            bypass_csp=True,  # This is needed to enable JavaScript execution on GitHub and others\n            extra_http_headers=self.headers,\n            ignore_https_errors=True,\n            proxy=proxy,\n            service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'),\n            # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers\n            user_agent=manage_user_agent(headers=self.headers),\n        )\n\n        self.page = await self.context.new_page()\n\n        # self.page.set_default_navigation_timeout(keep_open)\n        self.page.set_default_timeout(keep_open)\n        # Set event handlers\n        self.page.on(\"close\", self.mark_as_closed)\n        # Listen for all console events and handle errors\n        self.page.on(\"console\", lambda msg: print(f\"Browser steps console - {msg.type}: {msg.text} {msg.args}\"))\n\n        logger.debug(f\"Time to browser setup {time.time()-now:.2f}s\")\n        await self.page.wait_for_timeout(1 * 1000)\n\n    def mark_as_closed(self):\n        logger.debug(\"Page closed, cleaning up..\")\n        # Note: This is called from a sync context (event handler)\n        # so we'll just mark as cleaned up and let __del__ handle the rest\n        self._is_cleaned_up = True\n\n    async def cleanup(self):\n        \"\"\"Properly clean up all resources to prevent memory leaks\"\"\"\n        if self._is_cleaned_up:\n            return\n            \n        logger.debug(\"Cleaning up browser steps resources\")\n        \n        # Clean up page\n        if hasattr(self, 'page') and self.page is not None:\n            try:\n                # Force garbage collection before closing\n                await self.page.request_gc()\n            except Exception as e:\n                logger.debug(f\"Error during page garbage collection: {str(e)}\")\n                \n            try:\n                # Remove event listeners before closing\n                self.page.remove_listener(\"close\", self.mark_as_closed)\n            except Exception as e:\n                logger.debug(f\"Error removing event listeners: {str(e)}\")\n                \n            try:\n                await self.page.close()\n            except Exception as e:\n                logger.debug(f\"Error closing page: {str(e)}\")\n            \n            self.page = None\n\n        # Clean up context\n        if hasattr(self, 'context') and self.context is not None:\n            try:\n                await self.context.close()\n            except Exception as e:\n                logger.debug(f\"Error closing context: {str(e)}\")\n            \n            self.context = None\n            \n        self._is_cleaned_up = True\n        logger.debug(\"Browser steps resources cleanup complete\")\n\n    @property\n    def has_expired(self):\n        if not self.page or self._is_cleaned_up:\n            return True\n        \n        # Check if session has expired based on age\n        max_age_seconds = int(os.getenv(\"BROWSER_STEPS_MAX_AGE_SECONDS\", 60 * 10))  # Default 10 minutes\n        if (time.time() - self.age_start) > max_age_seconds:\n            logger.debug(f\"Browser steps session expired after {max_age_seconds} seconds\")\n            return True\n            \n        return False\n\n    async def get_current_state(self):\n        \"\"\"Return the screenshot and interactive elements mapping, generally always called after action_()\"\"\"\n        import importlib.resources\n        import json\n        # because we for now only run browser steps in playwright mode (not puppeteer mode)\n        from changedetectionio.content_fetchers.playwright import capture_full_page_async\n\n        # Safety check - don't proceed if resources are cleaned up\n        if self._is_cleaned_up or self.page is None:\n            logger.warning(\"Attempted to get current state after cleanup\")\n            return (None, None)\n\n        xpath_element_js = importlib.resources.files(\"changedetectionio.content_fetchers.res\").joinpath('xpath_element_scraper.js').read_text(encoding=\"utf-8\")\n\n        now = time.time()\n        await self.page.wait_for_timeout(1 * 1000)\n\n        screenshot = None\n        xpath_data = None\n        \n        try:\n            # Get screenshot first\n            screenshot = await capture_full_page_async(page=self.page)\n            if not screenshot:\n                logger.error(\"No screenshot was retrieved :((\")\n\n            logger.debug(f\"Time to get screenshot from browser {time.time() - now:.2f}s\")\n\n            # Then get interactive elements\n            now = time.time()\n            await self.page.evaluate(\"var include_filters=''\")\n            await self.page.request_gc()\n\n            scan_elements = 'a,button,input,select,textarea,i,th,td,p,li,h1,h2,h3,h4,div,span'\n\n            MAX_TOTAL_HEIGHT = int(os.getenv(\"SCREENSHOT_MAX_HEIGHT\", SCREENSHOT_MAX_HEIGHT_DEFAULT))\n            xpath_data = json.loads(await self.page.evaluate(xpath_element_js, {\n                \"visualselector_xpath_selectors\": scan_elements,\n                \"max_height\": MAX_TOTAL_HEIGHT\n            }))\n            await self.page.request_gc()\n\n            # Sort elements by size\n            xpath_data['size_pos'] = sorted(xpath_data['size_pos'], key=lambda k: k['width'] * k['height'], reverse=True)\n            logger.debug(f\"Time to scrape xPath element data in browser {time.time()-now:.2f}s\")\n            \n        except Exception as e:\n            logger.error(f\"Error getting current state: {str(e)}\")\n            # If the page has navigated (common with logins) then the context is destroyed on navigation, continue\n            # I'm not sure that this is required anymore because we have the \"expect navigation wrapper\" at the top\n            if \"Execution context was destroyed\" in str(e):\n                logger.debug(\"Execution context was destroyed, most likely because of navigation, continuing...\")\n            pass\n\n            # Attempt recovery - force garbage collection\n            try:\n                await self.page.request_gc()\n            except:\n                pass\n        \n        # Request garbage collection one final time\n        try:\n            await self.page.request_gc()\n        except:\n            pass\n            \n        return (screenshot, xpath_data)\n\n"
  },
  {
    "path": "changedetectionio/conditions/__init__.py",
    "content": "from json_logic.builtins import BUILTINS\n\nfrom .exceptions import EmptyConditionRuleRowNotUsable\nfrom .pluggy_interface import plugin_manager  # Import the pluggy plugin manager\nfrom . import default_plugin\nfrom loguru import logger\n# List of all supported JSON Logic operators\noperator_choices = [\n    (None, \"Choose one - Operator\"),\n    (\">\", \"Greater Than\"),\n    (\"<\", \"Less Than\"),\n    (\">=\", \"Greater Than or Equal To\"),\n    (\"<=\", \"Less Than or Equal To\"),\n    (\"==\", \"Equals\"),\n    (\"!=\", \"Not Equals\"),\n    (\"in\", \"Contains\"),\n]\n\n# Fields available in the rules\nfield_choices = [\n    (None, \"Choose one - Field\"),\n]\n\n# The data we will feed the JSON Rules to see if it passes the test/conditions or not\nEXECUTE_DATA = {}\n\n\n# Define the extended operations dictionary\nCUSTOM_OPERATIONS = {\n    **BUILTINS,  # Include all standard operators\n}\n\ndef filter_complete_rules(ruleset):\n    rules = [\n        rule for rule in ruleset\n        if all(value not in (\"\", False, \"None\", None) for value in [rule[\"operator\"], rule[\"field\"], rule[\"value\"]])\n    ]\n    return rules\n\ndef convert_to_jsonlogic(logic_operator: str, rule_dict: list):\n    \"\"\"\n    Convert a structured rule dict into a JSON Logic rule.\n\n    :param rule_dict: Dictionary containing conditions.\n    :return: JSON Logic rule as a dictionary.\n    \"\"\"\n\n\n    json_logic_conditions = []\n\n    for condition in rule_dict:\n        operator = condition[\"operator\"]\n        field = condition[\"field\"]\n        value = condition[\"value\"]\n\n        if not operator or operator == 'None' or not value or not field:\n            raise EmptyConditionRuleRowNotUsable()\n\n        # Convert value to int/float if possible\n        try:\n            if isinstance(value, str) and \".\" in value and str != \"None\":\n                value = float(value)\n            else:\n                value = int(value)\n        except (ValueError, TypeError):\n            pass  # Keep as a string if conversion fails\n\n        # Handle different JSON Logic operators properly\n        if operator == \"in\":\n            json_logic_conditions.append({\"in\": [value, {\"var\": field}]})  # value first\n        elif operator in (\"!\", \"!!\", \"-\"):\n            json_logic_conditions.append({operator: [{\"var\": field}]})  # Unary operators\n        elif operator in (\"min\", \"max\", \"cat\"):\n            json_logic_conditions.append({operator: value})  # Multi-argument operators\n        else:\n            json_logic_conditions.append({operator: [{\"var\": field}, value]})  # Standard binary operators\n\n    return {logic_operator: json_logic_conditions} if len(json_logic_conditions) > 1 else json_logic_conditions[0]\n\n\ndef execute_ruleset_against_all_plugins(current_watch_uuid: str, application_datastruct, ephemeral_data={} ):\n    \"\"\"\n    Build our data and options by calling our plugins then pass it to jsonlogic and see if the conditions pass\n\n    :param ruleset: JSON Logic rule dictionary.\n    :param extracted_data: Dictionary containing the facts.   <-- maybe the app struct+uuid\n    :return: Dictionary of plugin results.\n    \"\"\"\n    from json_logic import jsonLogic\n\n    EXECUTE_DATA = {}\n    result = True\n    \n    watch = application_datastruct['watching'].get(current_watch_uuid)\n\n    if watch and watch.get(\"conditions\"):\n        logic_operator = \"and\" if watch.get(\"conditions_match_logic\", \"ALL\") == \"ALL\" else \"or\"\n        complete_rules = filter_complete_rules(watch['conditions'])\n        if complete_rules:\n            # Give all plugins a chance to update the data dict again (that we will test the conditions against)\n            for plugin in plugin_manager.get_plugins():\n                try:\n                    import concurrent.futures\n                    import time\n                    \n                    with concurrent.futures.ThreadPoolExecutor() as executor:\n                        future = executor.submit(\n                            plugin.add_data,\n                            current_watch_uuid=current_watch_uuid,\n                            application_datastruct=application_datastruct,\n                            ephemeral_data=ephemeral_data\n                        )\n                        logger.debug(f\"Trying plugin {plugin}....\")\n\n                        # Set a timeout of 10 seconds\n                        try:\n                            new_execute_data = future.result(timeout=10)\n                            if new_execute_data and isinstance(new_execute_data, dict):\n                                EXECUTE_DATA.update(new_execute_data)\n\n                        except concurrent.futures.TimeoutError:\n                            # The plugin took too long, abort processing for this watch\n                            raise Exception(f\"Plugin {plugin.__class__.__name__} took more than 10 seconds to run.\")\n                except Exception as e:\n                    # Log the error but continue with the next plugin\n                    import logging\n                    logging.error(f\"Error executing plugin {plugin.__class__.__name__}: {str(e)}\")\n                    continue\n\n            # Create the ruleset\n            ruleset = convert_to_jsonlogic(logic_operator=logic_operator, rule_dict=complete_rules)\n            \n            # Pass the custom operations dictionary to jsonLogic\n            if not jsonLogic(logic=ruleset, data=EXECUTE_DATA, operations=CUSTOM_OPERATIONS):\n                result = False\n\n    return {'executed_data': EXECUTE_DATA, 'result': result}\n\n# Load plugins dynamically\nfor plugin in plugin_manager.get_plugins():\n    new_ops = plugin.register_operators()\n    if isinstance(new_ops, dict):\n        CUSTOM_OPERATIONS.update(new_ops)\n\n    new_operator_choices = plugin.register_operator_choices()\n    if isinstance(new_operator_choices, list):\n        operator_choices.extend(new_operator_choices)\n\n    new_field_choices = plugin.register_field_choices()\n    if isinstance(new_field_choices, list):\n        field_choices.extend(new_field_choices)\n\ndef collect_ui_edit_stats_extras(watch):\n    \"\"\"Collect and combine HTML content from all plugins that implement ui_edit_stats_extras\"\"\"\n    extras_content = []\n    \n    for plugin in plugin_manager.get_plugins():\n        try:\n            content = plugin.ui_edit_stats_extras(watch=watch)\n            if content:\n                extras_content.append(content)\n        except Exception as e:\n            # Skip plugins that don't implement the hook or have errors\n            pass\n            \n    return \"\\n\".join(extras_content) if extras_content else \"\"\n\n"
  },
  {
    "path": "changedetectionio/conditions/blueprint.py",
    "content": "# Flask Blueprint Definition\nimport json\n\nfrom flask import Blueprint\n\nfrom changedetectionio.conditions import execute_ruleset_against_all_plugins\n\n\ndef construct_blueprint(datastore):\n    from changedetectionio.flask_app import login_optionally_required\n\n    conditions_blueprint = Blueprint('conditions', __name__, template_folder=\"templates\")\n\n    @conditions_blueprint.route(\"/<string:watch_uuid>/verify-condition-single-rule\", methods=['POST'])\n    @login_optionally_required\n    def verify_condition_single_rule(watch_uuid):\n        \"\"\"Verify a single condition rule against the current snapshot\"\"\"\n        from changedetectionio.processors.text_json_diff import prepare_filter_prevew\n        from flask import request, jsonify\n        from copy import deepcopy\n\n        ephemeral_data = {}\n\n        # Get the watch data\n        watch = datastore.data['watching'].get(watch_uuid)\n        if not watch:\n            return jsonify({'status': 'error', 'message': 'Watch not found'}), 404\n\n        # First use prepare_filter_prevew to process the form data\n        # This will return text_after_filter which is after all current form settings are applied\n        # Create ephemeral data with the text from the current snapshot\n\n        try:\n            # Call prepare_filter_prevew to get a processed version of the content with current form settings\n            # We'll ignore the returned response and just use the datastore which is modified by the function\n\n            # this should apply all filters etc so then we can run the CONDITIONS against the final output text\n            result = prepare_filter_prevew(datastore=datastore,\n                                           form_data=request.form,\n                                           watch_uuid=watch_uuid)\n\n            ephemeral_data['text'] = result.get('after_filter', '')\n            # Create a temporary watch data structure with this single rule\n            tmp_watch_data = deepcopy(datastore.data['watching'].get(watch_uuid))\n\n            # Override the conditions in the temporary watch\n            rule_json = request.args.get(\"rule\")\n            rule = json.loads(rule_json) if rule_json else None\n\n            # Should be key/value of field, operator, value\n            tmp_watch_data['conditions'] = [rule]\n            tmp_watch_data['conditions_match_logic'] = \"ALL\"  # Single rule, so use ALL\n\n            # Create a temporary application data structure for the rule check\n            temp_app_data = {\n                'watching': {\n                    watch_uuid: tmp_watch_data\n                }\n            }\n\n            # Execute the rule against the current snapshot with form data\n            result = execute_ruleset_against_all_plugins(\n                current_watch_uuid=watch_uuid,\n                application_datastruct=temp_app_data,\n                ephemeral_data=ephemeral_data\n            )\n\n            return jsonify({\n                'status': 'success',\n                'result': result.get('result'),\n                'data': result.get('executed_data'),\n                'message': 'Condition passes' if result else 'Condition does not pass'\n            })\n\n        except Exception as e:\n            return jsonify({\n                'status': 'error',\n                'message': f'Error verifying condition: {str(e)}'\n            }), 500\n\n    return conditions_blueprint"
  },
  {
    "path": "changedetectionio/conditions/default_plugin.py",
    "content": "import re\n\nimport pluggy\nfrom price_parser import Price\nfrom loguru import logger\n\nhookimpl = pluggy.HookimplMarker(\"changedetectionio_conditions\")\n\n\n@hookimpl\ndef register_operators():\n    def starts_with(_, text, prefix):\n        return text.lower().strip().startswith(str(prefix).strip().lower())\n\n    def ends_with(_, text, suffix):\n        return text.lower().strip().endswith(str(suffix).strip().lower())\n\n    def length_min(_, text, strlen):\n        return len(text) >= int(strlen)\n\n    def length_max(_, text, strlen):\n        return len(text) <= int(strlen)\n\n    # Custom function for case-insensitive regex matching\n    def contains_regex(_, text, pattern):\n        \"\"\"Returns True if `text` contains `pattern` (case-insensitive regex match).\"\"\"\n        return bool(re.search(pattern, str(text), re.IGNORECASE))\n\n    # Custom function for NOT matching case-insensitive regex\n    def not_contains_regex(_, text, pattern):\n        \"\"\"Returns True if `text` does NOT contain `pattern` (case-insensitive regex match).\"\"\"\n        return not bool(re.search(pattern, str(text), re.IGNORECASE))\n\n    def not_contains(_, text, pattern):\n        return not pattern in text\n\n    return {\n        \"!in\": not_contains,\n        \"!contains_regex\": not_contains_regex,\n        \"contains_regex\": contains_regex,\n        \"ends_with\": ends_with,\n        \"length_max\": length_max,\n        \"length_min\": length_min,\n        \"starts_with\": starts_with,\n    }\n\n@hookimpl\ndef register_operator_choices():\n    return [\n        (\"!in\", \"Does NOT Contain\"),\n        (\"starts_with\", \"Text Starts With\"),\n        (\"ends_with\", \"Text Ends With\"),\n        (\"length_min\", \"Length minimum\"),\n        (\"length_max\", \"Length maximum\"),\n        (\"contains_regex\", \"Text Matches Regex\"),\n        (\"!contains_regex\", \"Text Does NOT Match Regex\"),\n    ]\n\n@hookimpl\ndef register_field_choices():\n    return [\n        (\"extracted_number\", \"Extracted number after 'Filters & Triggers'\"),\n#        (\"meta_description\", \"Meta Description\"),\n#        (\"meta_keywords\", \"Meta Keywords\"),\n        (\"page_filtered_text\", \"Page text after 'Filters & Triggers'\"),\n        #(\"page_title\", \"Page <title>\"), # actual page title <title>\n    ]\n\n@hookimpl\ndef add_data(current_watch_uuid, application_datastruct, ephemeral_data):\n\n    res = {}\n    if 'text' in ephemeral_data:\n        res['page_filtered_text'] = ephemeral_data['text']\n\n        # Better to not wrap this in try/except so that the UI can see any errors\n        price = Price.fromstring(ephemeral_data.get('text'))\n        if price and price.amount != None:\n            # This is slightly misleading, it's extracting a PRICE not a Number..\n            res['extracted_number'] = float(price.amount)\n            logger.debug(f\"Extracted number result: '{price}' - returning float({res['extracted_number']})\")\n\n    return res\n"
  },
  {
    "path": "changedetectionio/conditions/exceptions.py",
    "content": "class EmptyConditionRuleRowNotUsable(Exception):\n    def __init__(self):\n        super().__init__(\"One of the 'conditions' rulesets is incomplete, cannot run.\")\n\n    def __str__(self):\n        return self.args[0]"
  },
  {
    "path": "changedetectionio/conditions/form.py",
    "content": "# Condition Rule Form (for each rule row)\nfrom wtforms import Form, SelectField, StringField, validators\nfrom wtforms import validators\n\nclass ConditionFormRow(Form):\n\n    # ✅ Ensure Plugins Are Loaded BEFORE Importing Choices\n    from changedetectionio.conditions import plugin_manager\n    from changedetectionio.conditions import operator_choices, field_choices\n    field = SelectField(\n        \"Field\",\n        choices=field_choices,\n        validators=[validators.Optional()]\n    )\n\n    operator = SelectField(\n        \"Operator\",\n        choices=operator_choices,\n        validators=[validators.Optional()]\n    )\n\n    value = StringField(\"Value\", validators=[validators.Optional()], render_kw={\"placeholder\": \"A value\"})\n\n    def validate(self, extra_validators=None):\n        # First, run the default validators\n        if not super().validate(extra_validators):\n            return False\n\n        # Custom validation logic\n        # If any of the operator/field/value is set, then they must be all set\n        if any(value not in (\"\", False, \"None\", None) for value in [self.operator.data, self.field.data, self.value.data]):\n            if not self.operator.data or self.operator.data == 'None':\n                self.operator.errors.append(\"Operator is required.\")\n                return False\n\n            if not self.field.data or self.field.data == 'None':\n                self.field.errors.append(\"Field is required.\")\n                return False\n\n            if not self.value.data:\n                self.value.errors.append(\"Value is required.\")\n                return False\n\n        return True  # Only return True if all conditions pass"
  },
  {
    "path": "changedetectionio/conditions/pluggy_interface.py",
    "content": "import pluggy\nimport os\nimport importlib\nimport sys\nfrom . import default_plugin\n\n# ✅ Ensure that the namespace in HookspecMarker matches PluginManager\nPLUGIN_NAMESPACE = \"changedetectionio_conditions\"\n\nhookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE)\nhookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE)\n\n\nclass ConditionsSpec:\n    \"\"\"Hook specifications for extending JSON Logic conditions.\"\"\"\n\n    @hookspec\n    def register_operators():\n        \"\"\"Return a dictionary of new JSON Logic operators.\"\"\"\n        pass\n\n    @hookspec\n    def register_operator_choices():\n        \"\"\"Return a list of new operator choices.\"\"\"\n        pass\n\n    @hookspec\n    def register_field_choices():\n        \"\"\"Return a list of new field choices.\"\"\"\n        pass\n\n    @hookspec\n    def add_data(current_watch_uuid, application_datastruct, ephemeral_data):\n        \"\"\"Add to the datadict\"\"\"\n        pass\n        \n    @hookspec\n    def ui_edit_stats_extras(watch):\n        \"\"\"Return HTML content to add to the stats tab in the edit view\"\"\"\n        pass\n\n# ✅ Set up Pluggy Plugin Manager\nplugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)\n\n# ✅ Register hookspecs (Ensures they are detected)\nplugin_manager.add_hookspecs(ConditionsSpec)\n\n# ✅ Register built-in plugins manually\nplugin_manager.register(default_plugin, \"default_plugin\")\n\n# ✅ Load plugins from the plugins directory\ndef load_plugins_from_directory():\n    plugins_dir = os.path.join(os.path.dirname(__file__), 'plugins')\n    if not os.path.exists(plugins_dir):\n        return\n        \n    # Get all Python files (excluding __init__.py)\n    for filename in os.listdir(plugins_dir):\n        if filename.endswith(\".py\") and filename != \"__init__.py\":\n            module_name = filename[:-3]  # Remove .py extension\n            module_path = f\"changedetectionio.conditions.plugins.{module_name}\"\n            \n            try:\n                module = importlib.import_module(module_path)\n                # Register the plugin with pluggy\n                plugin_manager.register(module, module_name)\n            except (ImportError, AttributeError) as e:\n                print(f\"Error loading plugin {module_name}: {e}\")\n\n# Load plugins from the plugins directory\nload_plugins_from_directory()\n\n# ✅ Discover installed plugins from external packages (if any)\nplugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE)\n"
  },
  {
    "path": "changedetectionio/conditions/plugins/__init__.py",
    "content": "# Import plugins package to make them discoverable"
  },
  {
    "path": "changedetectionio/conditions/plugins/levenshtein_plugin.py",
    "content": "\"\"\"\nLevenshtein distance and similarity plugin for text change detection.\nProvides metrics for measuring text similarity between snapshots.\n\"\"\"\nimport pluggy\nfrom loguru import logger\n\nLEVENSHTEIN_MAX_LEN_FOR_EDIT_STATS=100000\n\n# Support both plugin systems\nconditions_hookimpl = pluggy.HookimplMarker(\"changedetectionio_conditions\")\nglobal_hookimpl = pluggy.HookimplMarker(\"changedetectionio\")\n\ndef levenshtein_ratio_recent_history(watch, incoming_text=None):\n    try:\n        from Levenshtein import ratio, distance\n        k = list(watch.history.keys())\n        a = None\n        b = None\n\n        # When called from ui_edit_stats_extras, we don't have incoming_text\n        if incoming_text is None:\n            a = watch.get_history_snapshot(timestamp=k[-1])  # Latest snapshot\n            b = watch.get_history_snapshot(timestamp=k[-2])  # Previous snapshot\n\n        # Needs atleast one snapshot\n        elif len(k) >= 1: # Should be atleast one snapshot to compare against\n            a = watch.get_history_snapshot(timestamp=k[-1]) # Latest saved snapshot\n            b = incoming_text if incoming_text else k[-2]\n\n        if a and b:\n            distance_value = distance(a, b)\n            ratio_value = ratio(a, b)\n            return {\n                'distance': distance_value,\n                'ratio': ratio_value,\n                'percent_similar': round(ratio_value * 100, 2)\n            }\n    except Exception as e:\n        logger.warning(f\"Unable to calc similarity: {str(e)}\")\n\n    return ''\n\n@conditions_hookimpl\ndef register_operators():\n    pass\n\n@conditions_hookimpl\ndef register_operator_choices():\n    pass\n\n\n@conditions_hookimpl\ndef register_field_choices():\n    return [\n        (\"levenshtein_ratio\", \"Levenshtein - Text similarity ratio\"),\n        (\"levenshtein_distance\", \"Levenshtein - Text change distance\"),\n    ]\n\n@conditions_hookimpl\ndef add_data(current_watch_uuid, application_datastruct, ephemeral_data):\n    res = {}\n    watch = application_datastruct['watching'].get(current_watch_uuid)\n    # ephemeral_data['text'] will be the current text after filters, they may have edited filters but not saved them yet etc\n\n    if watch and 'text' in ephemeral_data:\n        lev_data = levenshtein_ratio_recent_history(watch, ephemeral_data.get('text',''))\n        if isinstance(lev_data, dict):\n            res['levenshtein_ratio'] = lev_data.get('ratio', 0)\n            res['levenshtein_similarity'] = lev_data.get('percent_similar', 0)\n            res['levenshtein_distance'] = lev_data.get('distance', 0)\n\n    return res\n\n@global_hookimpl\ndef ui_edit_stats_extras(watch):\n    \"\"\"Add Levenshtein stats to the UI using the global plugin system\"\"\"\n    \"\"\"Generate the HTML for Levenshtein stats - shared by both plugin systems\"\"\"\n    if len(watch.history.keys()) < 2:\n        return \"<p>Not enough history to calculate Levenshtein metrics</p>\"\n\n\n    # Protection against the algorithm getting stuck on huge documents\n    k = list(watch.history.keys())\n    if any(\n            len(watch.get_history_snapshot(timestamp=k[idx])) > LEVENSHTEIN_MAX_LEN_FOR_EDIT_STATS\n            for idx in (-1, -2)\n            if len(k) >= abs(idx)\n    ):\n        return \"<p>Snapshot too large for edit statistics, skipping.</p>\"\n\n    try:\n        lev_data = levenshtein_ratio_recent_history(watch)\n        if not lev_data or not isinstance(lev_data, dict):\n            return \"<p>Unable to calculate Levenshtein metrics</p>\"\n            \n        html = f\"\"\"\n        <div class=\"levenshtein-stats\">\n            <h4>Levenshtein Text Similarity Details</h4>\n            <table class=\"pure-table\">\n                <tbody>\n                    <tr>\n                        <td>Raw distance (edits needed)</td>\n                        <td>{lev_data['distance']}</td>\n                    </tr>\n                    <tr>\n                        <td>Similarity ratio</td>\n                        <td>{lev_data['ratio']:.4f}</td>\n                    </tr>\n                    <tr>\n                        <td>Percent similar</td>\n                        <td>{lev_data['percent_similar']}%</td>\n                    </tr>\n                </tbody>\n            </table>\n            <p style=\"font-size: 80%;\">Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one into the other.</p>\n        </div>\n        \"\"\"\n        return html\n    except Exception as e:\n        logger.error(f\"Error generating Levenshtein UI extras: {str(e)}\")\n        return \"<p>Error calculating Levenshtein metrics</p>\"\n        \n"
  },
  {
    "path": "changedetectionio/conditions/plugins/wordcount_plugin.py",
    "content": "\"\"\"\nWord count plugin for content analysis.\nProvides word count metrics for snapshot content.\n\"\"\"\nimport pluggy\nfrom loguru import logger\n\n# Support both plugin systems\nconditions_hookimpl = pluggy.HookimplMarker(\"changedetectionio_conditions\")\nglobal_hookimpl = pluggy.HookimplMarker(\"changedetectionio\")\n\ndef count_words_in_history(watch, incoming_text=None):\n    \"\"\"Count words in snapshot text\"\"\"\n    try:\n        if incoming_text is not None:\n            # When called from add_data with incoming text\n            return len(incoming_text.split())\n        elif watch.history.keys():\n            # When called from UI extras to count latest snapshot\n            latest_key = list(watch.history.keys())[-1]\n            latest_content = watch.get_history_snapshot(timestamp=latest_key)\n            return len(latest_content.split())\n        return 0\n    except Exception as e:\n        logger.error(f\"Error counting words: {str(e)}\")\n        return 0\n\n# Implement condition plugin hooks\n@conditions_hookimpl\ndef register_operators():\n    # No custom operators needed\n    return {}\n\n@conditions_hookimpl\ndef register_operator_choices():\n    # No custom operator choices needed\n    return []\n\n@conditions_hookimpl\ndef register_field_choices():\n    # Add a field that will be available in conditions\n    return [\n        (\"word_count\", \"Word count of content\"),\n    ]\n\n@conditions_hookimpl\ndef add_data(current_watch_uuid, application_datastruct, ephemeral_data):\n    \"\"\"Add word count data for conditions\"\"\"\n    result = {}\n    watch = application_datastruct['watching'].get(current_watch_uuid)\n    \n    if watch and 'text' in ephemeral_data:\n        word_count = count_words_in_history(watch, ephemeral_data['text'])\n        result['word_count'] = word_count\n    \n    return result\n\ndef _generate_stats_html(watch):\n    \"\"\"Generate the HTML content for the stats tab\"\"\"\n    word_count = count_words_in_history(watch)\n    \n    html = f\"\"\"\n    <div class=\"word-count-stats\">\n        <h4>Content Analysis</h4>\n        <table class=\"pure-table\">\n            <tbody>\n                <tr>\n                    <td>Word count (latest snapshot)</td>\n                    <td>{word_count}</td>\n                </tr>\n            </tbody>\n        </table>\n        <p style=\"font-size: 80%;\">Word count is a simple measure of content length, calculated by splitting text on whitespace.</p>\n    </div>\n    \"\"\"\n    return html\n\n@conditions_hookimpl\ndef ui_edit_stats_extras(watch):\n    \"\"\"Add word count stats to the UI through conditions plugin system\"\"\"\n    return _generate_stats_html(watch)\n\n@global_hookimpl\ndef ui_edit_stats_extras(watch):\n    \"\"\"Add word count stats to the UI using the global plugin system\"\"\"\n    return _generate_stats_html(watch)"
  },
  {
    "path": "changedetectionio/content_fetchers/__init__.py",
    "content": "import sys\nfrom changedetectionio.strtobool import strtobool\nfrom loguru import logger\nfrom changedetectionio.content_fetchers.exceptions import BrowserStepsStepException\nimport os\n\n# Visual Selector scraper - 'Button' is there because some sites have <button>OUT OF STOCK</button>.\nvisualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary,button'\n\n# Import hookimpl from centralized pluggy interface\nfrom changedetectionio.pluggy_interface import hookimpl\n\nSCREENSHOT_MAX_HEIGHT_DEFAULT = 20000\nSCREENSHOT_DEFAULT_QUALITY = 40\n\n# Maximum total height for the final image (When in stitch mode).\n# We limit this to 16000px due to the huge amount of RAM that was being used\n# Example: 16000 × 1400 × 3 = 67,200,000 bytes ≈ 64.1 MB (not including buffers in PIL etc)\nSCREENSHOT_MAX_TOTAL_HEIGHT = int(os.getenv(\"SCREENSHOT_MAX_HEIGHT\", SCREENSHOT_MAX_HEIGHT_DEFAULT))\n\n# The size at which we will switch to stitching method, when below this (and\n# MAX_TOTAL_HEIGHT which can be set by a user) we will use the default\n# screenshot method.\n# Increased from 8000 to 10000 for better performance (fewer chunks = faster)\n# Most modern GPUs support 16384x16384 textures, so 1280x10000 is safe\nSCREENSHOT_SIZE_STITCH_THRESHOLD = int(os.getenv(\"SCREENSHOT_CHUNK_HEIGHT\", 10000))\n\n# available_fetchers() will scan this implementation looking for anything starting with html_\n# this information is used in the form selections\nfrom changedetectionio.content_fetchers.requests import fetcher as html_requests\n\n\nimport importlib.resources\nXPATH_ELEMENT_JS = importlib.resources.files(\"changedetectionio.content_fetchers.res\").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')\nINSTOCK_DATA_JS = importlib.resources.files(\"changedetectionio.content_fetchers.res\").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')\nFAVICON_FETCHER_JS = importlib.resources.files(\"changedetectionio.content_fetchers.res\").joinpath('favicon-fetcher.js').read_text(encoding='utf-8')\n\n\ndef available_fetchers():\n    # See the if statement at the bottom of this file for how we switch between playwright and webdriver\n    import inspect\n    p = []\n\n    # Get built-in fetchers (but skip plugin fetchers that were added via setattr)\n    for name, obj in inspect.getmembers(sys.modules[__name__], inspect.isclass):\n        if inspect.isclass(obj):\n            # @todo html_ is maybe better as fetcher_ or something\n            # In this case, make sure to edit the default one in store.py and fetch_site_status.py\n            if name.startswith('html_'):\n                # Skip plugin fetchers that were already registered\n                if name not in _plugin_fetchers:\n                    t = tuple([name, obj.fetcher_description])\n                    p.append(t)\n\n    # Get plugin fetchers from cache (already loaded at module init)\n    for name, fetcher_class in _plugin_fetchers.items():\n        if hasattr(fetcher_class, 'fetcher_description'):\n            t = tuple([name, fetcher_class.fetcher_description])\n            p.append(t)\n        else:\n            logger.warning(f\"Plugin fetcher '{name}' does not have fetcher_description attribute\")\n\n    return p\n\n\ndef get_plugin_fetchers():\n    \"\"\"Load and return all plugin fetchers from the centralized plugin manager.\"\"\"\n    from changedetectionio.pluggy_interface import plugin_manager\n\n    fetchers = {}\n    try:\n        # Call the register_content_fetcher hook from all registered plugins\n        results = plugin_manager.hook.register_content_fetcher()\n        for result in results:\n            if result:\n                name, fetcher_class = result\n                fetchers[name] = fetcher_class\n                # Register in current module so hasattr() checks work\n                setattr(sys.modules[__name__], name, fetcher_class)\n                logger.info(f\"Registered plugin fetcher: {name} - {getattr(fetcher_class, 'fetcher_description', 'No description')}\")\n    except Exception as e:\n        logger.error(f\"Error loading plugin fetchers: {e}\")\n\n    return fetchers\n\n\n# Initialize plugins at module load time\n_plugin_fetchers = get_plugin_fetchers()\n\n\n# Decide which is the 'real' HTML webdriver, this is more a system wide config\n# rather than site-specific.\nuse_playwright_as_chrome_fetcher = os.getenv('PLAYWRIGHT_DRIVER_URL', False)\nif use_playwright_as_chrome_fetcher:\n    # @note - For now, browser steps always uses playwright\n    if not strtobool(os.getenv('FAST_PUPPETEER_CHROME_FETCHER', 'False')):\n        logger.debug('Using Playwright library as fetcher')\n        from .playwright import fetcher as html_webdriver\n    else:\n        logger.debug('Using direct Python Puppeteer library as fetcher')\n        from .puppeteer import fetcher as html_webdriver\n\nelse:\n    logger.debug(\"Falling back to selenium as fetcher\")\n    from .webdriver_selenium import fetcher as html_webdriver\n\n\n# Register built-in fetchers as plugins after all imports are complete\nfrom changedetectionio.pluggy_interface import register_builtin_fetchers\nregister_builtin_fetchers()\n\n"
  },
  {
    "path": "changedetectionio/content_fetchers/base.py",
    "content": "import os\nfrom abc import abstractmethod\nfrom loguru import logger\n\nfrom changedetectionio.content_fetchers import BrowserStepsStepException\n\n\ndef manage_user_agent(headers, current_ua=''):\n    \"\"\"\n    Basic setting of user-agent\n\n    NOTE!!!!!! The service that does the actual Chrome fetching should handle any anti-robot techniques\n    THERE ARE MANY WAYS THAT IT CAN BE DETECTED AS A ROBOT!!\n    This does not take care of\n    - Scraping of 'navigator' (platform, productSub, vendor, oscpu etc etc) browser object (navigator.appVersion) etc\n    - TCP/IP fingerprint JA3 etc\n    - Graphic rendering fingerprinting\n    - Your IP being obviously in a pool of bad actors\n    - Too many requests\n    - Scraping of SCH-UA browser replies (thanks google!!)\n    - Scraping of ServiceWorker, new window calls etc\n\n    See https://filipvitas.medium.com/how-to-set-user-agent-header-with-puppeteer-js-and-not-fail-28c7a02165da\n    Puppeteer requests https://github.com/dgtlmoon/pyppeteerstealth\n\n    :param page:\n    :param headers:\n    :return:\n    \"\"\"\n    # Ask it what the user agent is, if its obviously ChromeHeadless, switch it to the default\n    ua_in_custom_headers = headers.get('User-Agent')\n    if ua_in_custom_headers:\n        return ua_in_custom_headers\n\n    if not ua_in_custom_headers and current_ua:\n        current_ua = current_ua.replace('HeadlessChrome', 'Chrome')\n        return current_ua\n\n    return None\n\nclass Fetcher():\n    browser_connection_is_custom = None\n    browser_connection_url = None\n    browser_steps = None\n    browser_steps_screenshot_path = None\n    content = None\n    error = None\n    fetcher_description = \"No description\"\n    headers = {}\n    favicon_blob = None\n    instock_data = None\n    instock_data_js = \"\"\n    screenshot_format = None\n    status_code = None\n    webdriver_js_execute_code = None\n    xpath_data = None\n    xpath_element_js = \"\"\n\n    # Will be needed in the future by the VisualSelector, always get this where possible.\n    screenshot = False\n    system_http_proxy = os.getenv('HTTP_PROXY')\n    system_https_proxy = os.getenv('HTTPS_PROXY')\n\n    # Time ONTOP of the system defined env minimum time\n    render_extract_delay = 0\n\n    # Fetcher capability flags - subclasses should override these\n    # These indicate what features the fetcher supports\n    supports_browser_steps = False      # Can execute browser automation steps\n    supports_screenshots = False        # Can capture page screenshots\n    supports_xpath_element_data = False # Can extract xpath element positions/data for visual selector\n\n    # Screenshot element locking - prevents layout shifts during screenshot capture\n    # Only needed for visual comparison (image_ssim_diff processor)\n    # Locks element dimensions in the first viewport to prevent headers/ads from resizing\n    lock_viewport_elements = False      # Default: disabled for performance\n\n    def __init__(self, **kwargs):\n        if kwargs and 'screenshot_format' in kwargs:\n            self.screenshot_format = kwargs.get('screenshot_format')\n\n        # Allow lock_viewport_elements to be set via kwargs\n        if kwargs and 'lock_viewport_elements' in kwargs:\n            self.lock_viewport_elements = kwargs.get('lock_viewport_elements')\n\n\n    @classmethod\n    def get_status_icon_data(cls):\n        \"\"\"Return data for status icon to display in the watch overview.\n\n        This method can be overridden by subclasses to provide custom status icons.\n\n        Returns:\n            dict or None: Dictionary with icon data:\n                {\n                    'filename': 'icon-name.svg',  # Icon filename\n                    'alt': 'Alt text',            # Alt attribute\n                    'title': 'Tooltip text',      # Title attribute\n                    'style': 'height: 1em;'       # Optional inline CSS\n                }\n                Or None if no icon\n        \"\"\"\n        return None\n\n    def clear_content(self):\n        \"\"\"\n        Explicitly clear all content from memory to free up heap space.\n        Call this after content has been saved to disk.\n        \"\"\"\n        self.content = None\n        if hasattr(self, 'raw_content'):\n            self.raw_content = None\n        self.screenshot = None\n        self.xpath_data = None\n        # Keep headers and status_code as they're small\n\n    @abstractmethod\n    def get_error(self):\n        return self.error\n\n    @abstractmethod\n    async def run(self,\n                  fetch_favicon=True,\n                  current_include_filters=None,\n                  empty_pages_are_a_change=False,\n                  ignore_status_codes=False,\n                  is_binary=False,\n                  request_body=None,\n                  request_headers=None,\n                  request_method=None,\n                  timeout=None,\n                  url=None,\n                  watch_uuid=None,\n                  ):\n        # Should set self.error, self.status_code and self.content\n        pass\n\n    @abstractmethod\n    async def quit(self, watch=None):\n        return\n\n    @abstractmethod\n    def get_last_status_code(self):\n        return self.status_code\n\n    @abstractmethod\n    def screenshot_step(self, step_n):\n        if self.browser_steps_screenshot_path and not os.path.isdir(self.browser_steps_screenshot_path):\n            logger.debug(f\"> Creating data dir {self.browser_steps_screenshot_path}\")\n            os.mkdir(self.browser_steps_screenshot_path)\n        return None\n\n    @abstractmethod\n    # Return true/false if this checker is ready to run, in the case it needs todo some special config check etc\n    def is_ready(self):\n        return True\n\n    def get_all_headers(self):\n        \"\"\"\n        Get all headers but ensure all keys are lowercase\n        :return:\n        \"\"\"\n        return {k.lower(): v for k, v in self.headers.items()}\n\n    async def iterate_browser_steps(self, start_url=None):\n        from changedetectionio.browser_steps.browser_steps import steppable_browser_interface, browser_steps_get_valid_steps\n        from playwright._impl._errors import TimeoutError, Error\n        from changedetectionio.jinja2_custom import render as jinja_render\n        step_n = 0\n\n        if self.browser_steps:\n            interface = steppable_browser_interface(start_url=start_url)\n            interface.page = self.page\n            valid_steps = browser_steps_get_valid_steps(self.browser_steps)\n\n            for step in valid_steps:\n                step_n += 1\n                logger.debug(f\">> Iterating check - browser Step n {step_n} - {step['operation']}...\")\n                await self.screenshot_step(\"before-\" + str(step_n))\n                await self.save_step_html(\"before-\" + str(step_n))\n\n                try:\n                    optional_value = step['optional_value']\n                    selector = step['selector']\n                    # Support for jinja2 template in step values, with date module added\n                    if '{%' in step['optional_value'] or '{{' in step['optional_value']:\n                        optional_value = jinja_render(template_str=step['optional_value'])\n                    if '{%' in step['selector'] or '{{' in step['selector']:\n                        selector = jinja_render(template_str=step['selector'])\n\n                    await getattr(interface, \"call_action\")(action_name=step['operation'],\n                                                      selector=selector,\n                                                      optional_value=optional_value)\n                    await self.screenshot_step(step_n)\n                    await self.save_step_html(step_n)\n                except (Error, TimeoutError) as e:\n                    logger.debug(str(e))\n                    # Stop processing here\n                    raise BrowserStepsStepException(step_n=step_n, original_e=e)\n\n    # It's always good to reset these\n    def delete_browser_steps_screenshots(self):\n        import glob\n        if self.browser_steps_screenshot_path is not None:\n            dest = os.path.join(self.browser_steps_screenshot_path, 'step_*.jpeg')\n            files = glob.glob(dest)\n            for f in files:\n                if os.path.isfile(f):\n                    os.unlink(f)\n\n    def save_step_html(self, step_n):\n        if self.browser_steps_screenshot_path and not os.path.isdir(self.browser_steps_screenshot_path):\n            logger.debug(f\"> Creating data dir {self.browser_steps_screenshot_path}\")\n            os.mkdir(self.browser_steps_screenshot_path)\n        pass\n"
  },
  {
    "path": "changedetectionio/content_fetchers/exceptions/__init__.py",
    "content": "from loguru import logger\n\nclass Non200ErrorCodeReceived(Exception):\n    def __init__(self, status_code, url, screenshot=None, xpath_data=None, page_html=None):\n        # Set this so we can use it in other parts of the app\n        self.status_code = status_code\n        self.url = url\n        self.screenshot = screenshot\n        self.xpath_data = xpath_data\n        self.page_text = None\n\n        if page_html:\n            from changedetectionio import html_tools\n            self.page_text = html_tools.html_to_text(page_html)\n        return\n\n\nclass checksumFromPreviousCheckWasTheSame(Exception):\n    def __init__(self):\n        return\n\n\nclass JSActionExceptions(Exception):\n    def __init__(self, status_code, url, screenshot, message=''):\n        self.status_code = status_code\n        self.url = url\n        self.screenshot = screenshot\n        self.message = message\n        return\n\nclass BrowserConnectError(Exception):\n    msg = ''\n    def __init__(self, msg):\n        self.msg = msg\n        logger.error(f\"Browser connection error {msg}\")\n        return\n\nclass BrowserFetchTimedOut(Exception):\n    msg = ''\n    def __init__(self, msg):\n        self.msg = msg\n        logger.error(f\"Browser processing took too long - {msg}\")\n        return\n\nclass BrowserStepsStepException(Exception):\n    def __init__(self, step_n, original_e):\n        self.step_n = step_n\n        self.original_e = original_e\n        logger.debug(f\"Browser Steps exception at step {self.step_n} {str(original_e)}\")\n        return\n\n\n# @todo - make base Exception class that announces via logger()\nclass PageUnloadable(Exception):\n    def __init__(self, status_code=None, url='', message='', screenshot=False):\n        # Set this so we can use it in other parts of the app\n        self.status_code = status_code\n        self.url = url\n        self.screenshot = screenshot\n        self.message = message\n        return\n\nclass BrowserStepsInUnsupportedFetcher(Exception):\n    def __init__(self, url):\n        self.url = url\n        return\n\nclass EmptyReply(Exception):\n    def __init__(self, status_code, url, screenshot=None):\n        # Set this so we can use it in other parts of the app\n        self.status_code = status_code\n        self.url = url\n        self.screenshot = screenshot\n        return\n\n\nclass ScreenshotUnavailable(Exception):\n    def __init__(self, status_code, url, page_html=None):\n        # Set this so we can use it in other parts of the app\n        self.status_code = status_code\n        self.url = url\n        if page_html:\n            from changedetectionio.html_tools import html_to_text\n            self.page_text = html_to_text(page_html)\n        return\n\n\nclass ReplyWithContentButNoText(Exception):\n    def __init__(self, status_code, url, screenshot=None, has_filters=False, html_content='', xpath_data=None):\n        # Set this so we can use it in other parts of the app\n        self.status_code = status_code\n        self.url = url\n        self.screenshot = screenshot\n        self.has_filters = has_filters\n        self.html_content = html_content\n        self.xpath_data = xpath_data\n        return\n"
  },
  {
    "path": "changedetectionio/content_fetchers/playwright.py",
    "content": "import asyncio\nimport gc\nimport json\nimport os\nfrom urllib.parse import urlparse\n\nfrom loguru import logger\n\nfrom changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \\\n    SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, FAVICON_FETCHER_JS\nfrom changedetectionio.content_fetchers.base import Fetcher, manage_user_agent\nfrom changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable, \\\n    BrowserStepsStepException\n\n\nasync def capture_full_page_async(page, screenshot_format='JPEG', watch_uuid=None, lock_viewport_elements=False):\n    import os\n    import time\n\n    start = time.time()\n    watch_info = f\"[{watch_uuid}] \" if watch_uuid else \"\"\n\n    setup_start = time.time()\n    page_height = await page.evaluate(\"document.documentElement.scrollHeight\")\n    page_width = await page.evaluate(\"document.documentElement.scrollWidth\")\n    original_viewport = page.viewport_size\n    dimensions_time = time.time() - setup_start\n\n    logger.debug(f\"{watch_info}Playwright viewport size {page.viewport_size} page height {page_height} page width {page_width} (got dimensions in {dimensions_time:.2f}s)\")\n\n    # Use an approach similar to puppeteer: set a larger viewport and take screenshots in chunks\n    step_size = SCREENSHOT_SIZE_STITCH_THRESHOLD # Size that won't cause GPU to overflow\n    screenshot_chunks = []\n    y = 0\n    elements_locked = False\n\n    # Only lock viewport elements if explicitly enabled (for image_ssim_diff processor)\n    # This prevents headers/ads from resizing when viewport changes\n    if lock_viewport_elements and page_height > page.viewport_size['height']:\n        lock_start = time.time()\n        lock_elements_js_path = os.path.join(os.path.dirname(__file__), 'res', 'lock-elements-sizing.js')\n        with open(lock_elements_js_path, 'r') as f:\n            lock_elements_js = f.read()\n        await page.evaluate(lock_elements_js)\n        elements_locked = True\n        lock_time = time.time() - lock_start\n        logger.debug(f\"{watch_info}Viewport element locking enabled (took {lock_time:.2f}s)\")\n\n    if page_height > page.viewport_size['height']:\n        if page_height < step_size:\n            step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size\n        viewport_start = time.time()\n        logger.debug(f\"{watch_info}Setting bigger viewport to step through large page width W{page.viewport_size['width']}xH{step_size} because page_height > viewport_size\")\n        # Set viewport to a larger size to capture more content at once\n        await page.set_viewport_size({'width': page.viewport_size['width'], 'height': step_size})\n        viewport_time = time.time() - viewport_start\n        logger.debug(f\"{watch_info}Viewport changed to {page.viewport_size['width']}x{step_size} (took {viewport_time:.2f}s)\")\n\n    # Capture screenshots in chunks up to the max total height\n    capture_start = time.time()\n    chunk_times = []\n    # Use PNG for better quality (no compression artifacts), JPEG for smaller size\n    screenshot_type = screenshot_format.lower() if screenshot_format else 'jpeg'\n    # PNG should use quality 100, JPEG uses configurable quality\n    screenshot_quality = 100 if screenshot_type == 'png' else int(os.getenv(\"SCREENSHOT_QUALITY\", 72))\n\n    while y < min(page_height, SCREENSHOT_MAX_TOTAL_HEIGHT):\n        # Only scroll if not at the top (y > 0)\n        if y > 0:\n            await page.evaluate(f\"window.scrollTo(0, {y})\")\n\n        # Request GC only before screenshot (not 3x per chunk)\n        await page.request_gc()\n\n        screenshot_kwargs = {\n            'type': screenshot_type,\n            'full_page': False\n        }\n        # Only pass quality parameter for jpeg (PNG doesn't support it in Playwright)\n        if screenshot_type == 'jpeg':\n            screenshot_kwargs['quality'] = screenshot_quality\n\n        chunk_start = time.time()\n        screenshot_chunks.append(await page.screenshot(**screenshot_kwargs))\n        chunk_time = time.time() - chunk_start\n        chunk_times.append(chunk_time)\n        logger.debug(f\"{watch_info}Chunk {len(screenshot_chunks)} captured in {chunk_time:.2f}s\")\n        y += step_size\n\n    # Restore original viewport size\n    await page.set_viewport_size({'width': original_viewport['width'], 'height': original_viewport['height']})\n\n    # Unlock element dimensions if they were locked\n    if elements_locked:\n        unlock_elements_js_path = os.path.join(os.path.dirname(__file__), 'res', 'unlock-elements-sizing.js')\n        with open(unlock_elements_js_path, 'r') as f:\n            unlock_elements_js = f.read()\n        await page.evaluate(unlock_elements_js)\n        logger.debug(f\"{watch_info}Element dimensions unlocked after screenshot capture\")\n\n    capture_time = time.time() - capture_start\n    total_capture_time = sum(chunk_times)\n    logger.debug(f\"{watch_info}All {len(screenshot_chunks)} chunks captured in {capture_time:.2f}s (total chunk time: {total_capture_time:.2f}s)\")\n\n    # If we have multiple chunks, stitch them together\n    if len(screenshot_chunks) > 1:\n        stitch_start = time.time()\n        logger.debug(f\"{watch_info}Starting stitching of {len(screenshot_chunks)} chunks\")\n\n        # Always use spawn subprocess for ANY stitching (2+ chunks)\n        # PIL allocates at C level and Python GC never releases it - subprocess exit forces OS to reclaim\n        # Trade-off: 35MB resource_tracker vs 500MB+ PIL leak in main process\n        from changedetectionio.content_fetchers.screenshot_handler import stitch_images_worker_raw_bytes\n        import multiprocessing\n        import struct\n\n        ctx = multiprocessing.get_context('spawn')\n        parent_conn, child_conn = ctx.Pipe()\n        p = ctx.Process(target=stitch_images_worker_raw_bytes, args=(child_conn, page_height, SCREENSHOT_MAX_TOTAL_HEIGHT))\n        p.start()\n\n        # Send via raw bytes (no pickle)\n        parent_conn.send_bytes(struct.pack('I', len(screenshot_chunks)))\n        for chunk in screenshot_chunks:\n            parent_conn.send_bytes(chunk)\n\n        screenshot = parent_conn.recv_bytes()\n        p.join()\n\n        parent_conn.close()\n        child_conn.close()\n        del p, parent_conn, child_conn\n\n        stitch_time = time.time() - stitch_start\n        total_time = time.time() - start\n        setup_time = total_time - capture_time - stitch_time\n        logger.debug(\n            f\"{watch_info}Screenshot complete - Page height: {page_height}px, Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT}px | \"\n            f\"Setup: {setup_time:.2f}s, Capture: {capture_time:.2f}s, Stitching: {stitch_time:.2f}s, Total: {total_time:.2f}s\")\n        return screenshot\n\n    total_time = time.time() - start\n    setup_time = total_time - capture_time\n    logger.debug(\n        f\"{watch_info}Screenshot complete - Page height: {page_height}px, Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT}px | \"\n        f\"Setup: {setup_time:.2f}s, Single chunk: {capture_time:.2f}s, Total: {total_time:.2f}s\")\n\n    return screenshot_chunks[0]\n\nclass fetcher(Fetcher):\n    fetcher_description = \"Playwright {}/Javascript\".format(\n        os.getenv(\"PLAYWRIGHT_BROWSER_TYPE\", 'chromium').capitalize()\n    )\n    if os.getenv(\"PLAYWRIGHT_DRIVER_URL\"):\n        fetcher_description += \" via '{}'\".format(os.getenv(\"PLAYWRIGHT_DRIVER_URL\"))\n\n    browser_type = ''\n    command_executor = ''\n\n    # Configs for Proxy setup\n    # In the ENV vars, is prefixed with \"playwright_proxy_\", so it is for example \"playwright_proxy_server\"\n    playwright_proxy_settings_mappings = ['bypass', 'server', 'username', 'password']\n\n    proxy = None\n\n    # Capability flags\n    supports_browser_steps = True\n    supports_screenshots = True\n    supports_xpath_element_data = True\n\n    @classmethod\n    def get_status_icon_data(cls):\n        \"\"\"Return Chrome browser icon data for Playwright fetcher.\"\"\"\n        return {\n            'filename': 'google-chrome-icon.png',\n            'alt': 'Using a Chrome browser',\n            'title': 'Using a Chrome browser'\n        }\n\n    def __init__(self, proxy_override=None, custom_browser_connection_url=None, **kwargs):\n        super().__init__(**kwargs)\n\n        self.browser_type = os.getenv(\"PLAYWRIGHT_BROWSER_TYPE\", 'chromium').strip('\"')\n\n        if custom_browser_connection_url:\n            self.browser_connection_is_custom = True\n            self.browser_connection_url = custom_browser_connection_url\n        else:\n            # Fallback to fetching from system\n            # .strip('\"') is going to save someone a lot of time when they accidently wrap the env value\n            self.browser_connection_url = os.getenv(\"PLAYWRIGHT_DRIVER_URL\", 'ws://playwright-chrome:3000').strip('\"')\n\n        # If any proxy settings are enabled, then we should setup the proxy object\n        proxy_args = {}\n        for k in self.playwright_proxy_settings_mappings:\n            v = os.getenv('playwright_proxy_' + k, False)\n            if v:\n                proxy_args[k] = v.strip('\"')\n\n        if proxy_args:\n            self.proxy = proxy_args\n\n        # allow per-watch proxy selection override\n        if proxy_override:\n            self.proxy = {'server': proxy_override}\n\n        if self.proxy:\n            # Playwright needs separate username and password values\n            parsed = urlparse(self.proxy.get('server'))\n            if parsed.username:\n                self.proxy['username'] = parsed.username\n                self.proxy['password'] = parsed.password\n\n    async def screenshot_step(self, step_n=''):\n        super().screenshot_step(step_n=step_n)\n        watch_uuid = getattr(self, 'watch_uuid', None)\n        screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)\n\n        # Request GC immediately after screenshot to free memory\n        # Screenshots can be large and browser steps take many of them\n        await self.page.request_gc()\n\n        if self.browser_steps_screenshot_path is not None:\n            destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))\n            logger.debug(f\"Saving step screenshot to {destination}\")\n            with open(destination, 'wb') as f:\n                f.write(screenshot)\n            # Clear local reference to allow screenshot bytes to be collected\n            del screenshot\n            gc.collect()\n\n    async def save_step_html(self, step_n):\n        super().save_step_html(step_n=step_n)\n        content = await self.page.content()\n\n        # Request GC after getting page content\n        await self.page.request_gc()\n\n        destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n))\n        logger.debug(f\"Saving step HTML to {destination}\")\n        with open(destination, 'w', encoding='utf-8') as f:\n            f.write(content)\n        # Clear local reference\n        del content\n        gc.collect()\n\n    async def run(self,\n                  fetch_favicon=True,\n                  current_include_filters=None,\n                  empty_pages_are_a_change=False,\n                  ignore_status_codes=False,\n                  is_binary=False,\n                  request_body=None,\n                  request_headers=None,\n                  request_method=None,\n                  screenshot_format=None,\n                  timeout=None,\n                  url=None,\n                  watch_uuid=None,\n                  ):\n\n        from playwright.async_api import async_playwright\n        import playwright._impl._errors\n        import time\n        self.delete_browser_steps_screenshots()\n        self.watch_uuid = watch_uuid  # Store for use in screenshot_step\n        response = None\n\n        async with async_playwright() as p:\n            browser_type = getattr(p, self.browser_type)\n\n            # Seemed to cause a connection Exception even tho I can see it connect\n            # self.browser = browser_type.connect(self.command_executor, timeout=timeout*1000)\n            # 60,000 connection timeout only\n            browser = await browser_type.connect_over_cdp(self.browser_connection_url, timeout=60000)\n\n            # SOCKS5 with authentication is not supported (yet)\n            # https://github.com/microsoft/playwright/issues/10567\n\n            # Set user agent to prevent Cloudflare from blocking the browser\n            # Use the default one configured in the App.py model that's passed from fetch_site_status.py\n            context = await browser.new_context(\n                accept_downloads=False,  # Should never be needed\n                bypass_csp=True,  # This is needed to enable JavaScript execution on GitHub and others\n                extra_http_headers=request_headers,\n                ignore_https_errors=True,\n                proxy=self.proxy,\n                service_workers=os.getenv('PLAYWRIGHT_SERVICE_WORKERS', 'allow'), # Should be `allow` or `block` - sites like YouTube can transmit large amounts of data via Service Workers\n                user_agent=manage_user_agent(headers=request_headers),\n            )\n\n            self.page = await context.new_page()\n\n            # Listen for all console events and handle errors\n            self.page.on(\"console\", lambda msg: logger.debug(f\"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}\"))\n\n            # Re-use as much code from browser steps as possible so its the same\n            from changedetectionio.browser_steps.browser_steps import steppable_browser_interface\n            browsersteps_interface = steppable_browser_interface(start_url=url)\n            browsersteps_interface.page = self.page\n\n            response = await browsersteps_interface.action_goto_url(value=url)\n\n            if response is None:\n                await context.close()\n                await browser.close()\n                logger.debug(\"Content Fetcher > Response object from the browser communication was none\")\n                raise EmptyReply(url=url, status_code=None)\n\n            # In async_playwright, all_headers() returns a coroutine\n            try:\n                self.headers = await response.all_headers()\n            except TypeError:\n                # Fallback for sync version\n                self.headers = response.all_headers()\n\n            try:\n                if self.webdriver_js_execute_code is not None and len(self.webdriver_js_execute_code):\n                    await browsersteps_interface.action_execute_js(value=self.webdriver_js_execute_code, selector=None)\n            except playwright._impl._errors.TimeoutError as e:\n                await context.close()\n                await browser.close()\n                # This can be ok, we will try to grab what we could retrieve\n                pass\n            except Exception as e:\n                logger.debug(f\"Content Fetcher > Other exception when executing custom JS code {str(e)}\")\n                await context.close()\n                await browser.close()\n                raise PageUnloadable(url=url, status_code=None, message=str(e))\n\n            extra_wait = int(os.getenv(\"WEBDRIVER_DELAY_BEFORE_CONTENT_READY\", 5)) + self.render_extract_delay\n            await self.page.wait_for_timeout(extra_wait * 1000)\n\n            try:\n                self.status_code = response.status\n            except Exception as e:\n                # https://github.com/dgtlmoon/changedetection.io/discussions/2122#discussioncomment-8241962\n                logger.critical(f\"Response from the browser/Playwright did not have a status_code! Response follows.\")\n                logger.critical(response)\n                await context.close()\n                await browser.close()\n                raise PageUnloadable(url=url, status_code=None, message=str(e))\n\n            if fetch_favicon:\n                try:\n                    self.favicon_blob = await self.page.evaluate(FAVICON_FETCHER_JS)\n                    await self.page.request_gc()\n                except Exception as e:\n                    logger.error(f\"Error fetching FavIcon info {str(e)}, continuing.\")\n\n            if self.status_code != 200 and not ignore_status_codes:\n                screenshot = await capture_full_page_async(self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)\n                # Finally block will handle cleanup\n                raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)\n\n            if not empty_pages_are_a_change and len((await self.page.content()).strip()) == 0:\n                logger.debug(\"Content Fetcher > Content was empty, empty_pages_are_a_change = False\")\n                await context.close()\n                await browser.close()\n                raise EmptyReply(url=url, status_code=response.status)\n\n            # Wrap remaining operations in try/finally to ensure cleanup\n            try:\n                # Run Browser Steps here\n                if self.browser_steps:\n                    try:\n                        await self.iterate_browser_steps(start_url=url)\n                    except BrowserStepsStepException:\n                        # Finally block will handle cleanup\n                        raise\n\n                    await self.page.wait_for_timeout(extra_wait * 1000)\n\n                now = time.time()\n                # So we can find an element on the page where its selector was entered manually (maybe not xPath etc)\n                if current_include_filters is not None:\n                    await self.page.evaluate(\"var include_filters={}\".format(json.dumps(current_include_filters)))\n                else:\n                    await self.page.evaluate(\"var include_filters=''\")\n                await self.page.request_gc()\n\n                # request_gc before and after evaluate to free up memory\n                # @todo browsersteps etc\n                MAX_TOTAL_HEIGHT = int(os.getenv(\"SCREENSHOT_MAX_HEIGHT\", SCREENSHOT_MAX_HEIGHT_DEFAULT))\n                self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {\n                    \"visualselector_xpath_selectors\": visualselector_xpath_selectors,\n                    \"max_height\": MAX_TOTAL_HEIGHT\n                })\n                await self.page.request_gc()\n\n                self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)\n                await self.page.request_gc()\n\n                self.content = await self.page.content()\n                await self.page.request_gc()\n                logger.debug(f\"Scrape xPath element data in browser done in {time.time() - now:.2f}s\")\n\n\n                # Bug 3 in Playwright screenshot handling\n                # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it\n                # JPEG is better here because the screenshots can be very very large\n\n                # Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded\n                # which will significantly increase the IO size between the server and client, it's recommended to use the lowest\n                # acceptable screenshot quality here\n                # The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage\n                self.screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)\n\n                # Force aggressive memory cleanup - screenshots are large and base64 decode creates temporary buffers\n                await self.page.request_gc()\n                gc.collect()\n\n            except ScreenshotUnavailable:\n                # Re-raise screenshot unavailable exceptions\n                raise ScreenshotUnavailable(url=url, status_code=self.status_code)\n\n            finally:\n                # Clean up resources properly with timeouts to prevent hanging\n                try:\n                    if hasattr(self, 'page') and self.page:\n                        await self.page.request_gc()\n                        await asyncio.wait_for(self.page.close(), timeout=5.0)\n                        logger.debug(f\"Successfully closed page for {url}\")\n                except asyncio.TimeoutError:\n                    logger.warning(f\"Timed out closing page for {url} (5s)\")\n                except Exception as e:\n                    logger.warning(f\"Error closing page for {url}: {e}\")\n                finally:\n                    self.page = None\n\n                try:\n                    if context:\n                        await asyncio.wait_for(context.close(), timeout=5.0)\n                        logger.debug(f\"Successfully closed context for {url}\")\n                except asyncio.TimeoutError:\n                    logger.warning(f\"Timed out closing context for {url} (5s)\")\n                except Exception as e:\n                    logger.warning(f\"Error closing context for {url}: {e}\")\n                finally:\n                    context = None\n\n                try:\n                    if browser:\n                        await asyncio.wait_for(browser.close(), timeout=5.0)\n                        logger.debug(f\"Successfully closed browser connection for {url}\")\n                except asyncio.TimeoutError:\n                    logger.warning(f\"Timed out closing browser connection for {url} (5s)\")\n                except Exception as e:\n                    logger.warning(f\"Error closing browser for {url}: {e}\")\n                finally:\n                    browser = None\n\n                # Force Python GC to release Playwright resources immediately\n                # Playwright objects can have circular references that delay cleanup\n                gc.collect()\n\n\n# Plugin registration for built-in fetcher\nclass PlaywrightFetcherPlugin:\n    \"\"\"Plugin class that registers the Playwright fetcher as a built-in plugin.\"\"\"\n\n    def register_content_fetcher(self):\n        \"\"\"Register the Playwright fetcher\"\"\"\n        return ('html_webdriver', fetcher)\n\n\n# Create module-level instance for plugin registration\nplaywright_plugin = PlaywrightFetcherPlugin()\n\n\n\n"
  },
  {
    "path": "changedetectionio/content_fetchers/puppeteer.py",
    "content": "import asyncio\nimport gc\nimport json\nimport os\nimport websockets.exceptions\nfrom urllib.parse import urlparse\n\nfrom loguru import logger\n\nfrom changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \\\n    SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \\\n    SCREENSHOT_MAX_TOTAL_HEIGHT, FAVICON_FETCHER_JS\nfrom changedetectionio.content_fetchers.base import Fetcher, manage_user_agent\nfrom changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \\\n    BrowserConnectError\n\n\n# Bug 3 in Playwright screenshot handling\n# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it\n\n# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded\n# which will significantly increase the IO size between the server and client, it's recommended to use the lowest\n# acceptable screenshot quality here\nasync def capture_full_page(page, screenshot_format='JPEG', watch_uuid=None, lock_viewport_elements=False):\n    import os\n    import time\n\n    start = time.time()\n    watch_info = f\"[{watch_uuid}] \" if watch_uuid else \"\"\n\n    setup_start = time.time()\n    page_height = await page.evaluate(\"document.documentElement.scrollHeight\")\n    page_width = await page.evaluate(\"document.documentElement.scrollWidth\")\n    original_viewport = page.viewport\n    dimensions_time = time.time() - setup_start\n\n    logger.debug(f\"{watch_info}Puppeteer viewport size {page.viewport} page height {page_height} page width {page_width} (got dimensions in {dimensions_time:.2f}s)\")\n\n    # Bug 3 in Playwright screenshot handling\n    # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it\n    # JPEG is better here because the screenshots can be very very large\n\n    # Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded\n    # which will significantly increase the IO size between the server and client, it's recommended to use the lowest\n    # acceptable screenshot quality here\n\n    # Use PNG for better quality (no compression artifacts), JPEG for smaller size\n    screenshot_type = screenshot_format.lower() if screenshot_format else 'jpeg'\n    # PNG should use quality 100, JPEG uses configurable quality\n    screenshot_quality = 100 if screenshot_type == 'png' else int(os.getenv(\"SCREENSHOT_QUALITY\", 72))\n\n    step_size = SCREENSHOT_SIZE_STITCH_THRESHOLD # Something that will not cause the GPU to overflow when taking the screenshot\n    screenshot_chunks = []\n    y = 0\n    elements_locked = False\n\n    # Only lock viewport elements if explicitly enabled (for image_ssim_diff processor)\n    # This prevents headers/ads from resizing when viewport changes\n    if lock_viewport_elements and page_height > page.viewport['height']:\n        lock_start = time.time()\n        lock_elements_js_path = os.path.join(os.path.dirname(__file__), 'res', 'lock-elements-sizing.js')\n        file_read_start = time.time()\n        with open(lock_elements_js_path, 'r') as f:\n            lock_elements_js = f.read()\n        file_read_time = time.time() - file_read_start\n\n        evaluate_start = time.time()\n        await page.evaluate(lock_elements_js)\n        evaluate_time = time.time() - evaluate_start\n\n        elements_locked = True\n        lock_time = time.time() - lock_start\n        logger.debug(f\"{watch_info}Viewport element locking enabled - File read: {file_read_time:.3f}s, Browser evaluate: {evaluate_time:.2f}s, Total: {lock_time:.2f}s\")\n\n    if page_height > page.viewport['height']:\n        if page_height < step_size:\n            step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size\n        viewport_start = time.time()\n        await page.setViewport({'width': page.viewport['width'], 'height': step_size})\n        viewport_time = time.time() - viewport_start\n        logger.debug(f\"{watch_info}Viewport changed to {page.viewport['width']}x{step_size} (took {viewport_time:.2f}s)\")\n\n    capture_start = time.time()\n    chunk_times = []\n    while y < min(page_height, SCREENSHOT_MAX_TOTAL_HEIGHT):\n        # better than scrollTo incase they override it in the page\n        await page.evaluate(\n            \"\"\"(y) => {\n                const el = document.scrollingElement;\n                if (el) el.scrollTop = y;\n            }\"\"\",\n            y\n        )\n\n        screenshot_kwargs = {\n            'type_': screenshot_type,\n            'fullPage': False\n        }\n        # PNG doesn't support quality parameter in Puppeteer\n        if screenshot_type == 'jpeg':\n            screenshot_kwargs['quality'] = screenshot_quality\n\n        chunk_start = time.time()\n        screenshot_chunks.append(await page.screenshot(**screenshot_kwargs))\n        chunk_time = time.time() - chunk_start\n        chunk_times.append(chunk_time)\n        logger.debug(f\"{watch_info}Chunk {len(screenshot_chunks)} captured in {chunk_time:.2f}s\")\n        y += step_size\n\n    await page.setViewport({'width': original_viewport['width'], 'height': original_viewport['height']})\n\n    # Unlock element dimensions if they were locked\n    if elements_locked:\n        unlock_elements_js_path = os.path.join(os.path.dirname(__file__), 'res', 'unlock-elements-sizing.js')\n        with open(unlock_elements_js_path, 'r') as f:\n            unlock_elements_js = f.read()\n        await page.evaluate(unlock_elements_js)\n        logger.debug(f\"{watch_info}Element dimensions unlocked after screenshot capture\")\n\n    capture_time = time.time() - capture_start\n    total_capture_time = sum(chunk_times)\n    logger.debug(f\"{watch_info}All {len(screenshot_chunks)} chunks captured in {capture_time:.2f}s (total chunk time: {total_capture_time:.2f}s)\")\n\n    if len(screenshot_chunks) > 1:\n        stitch_start = time.time()\n        logger.debug(f\"{watch_info}Starting stitching of {len(screenshot_chunks)} chunks\")\n\n        # Always use spawn subprocess for ANY stitching (2+ chunks)\n        # PIL allocates at C level and Python GC never releases it - subprocess exit forces OS to reclaim\n        # Trade-off: 35MB resource_tracker vs 500MB+ PIL leak in main process\n        from changedetectionio.content_fetchers.screenshot_handler import stitch_images_worker_raw_bytes\n        import multiprocessing\n        import struct\n\n        ctx = multiprocessing.get_context('spawn')\n        parent_conn, child_conn = ctx.Pipe()\n        p = ctx.Process(target=stitch_images_worker_raw_bytes, args=(child_conn, page_height, SCREENSHOT_MAX_TOTAL_HEIGHT))\n        p.start()\n\n        # Send via raw bytes (no pickle)\n        parent_conn.send_bytes(struct.pack('I', len(screenshot_chunks)))\n        for chunk in screenshot_chunks:\n            parent_conn.send_bytes(chunk)\n\n        screenshot = parent_conn.recv_bytes()\n        p.join()\n\n        parent_conn.close()\n        child_conn.close()\n        del p, parent_conn, child_conn\n\n        stitch_time = time.time() - stitch_start\n        total_time = time.time() - start\n        setup_time = total_time - capture_time - stitch_time\n        logger.debug(\n            f\"{watch_info}Screenshot complete - Page height: {page_height}px, Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT}px | \"\n            f\"Setup: {setup_time:.2f}s, Capture: {capture_time:.2f}s, Stitching: {stitch_time:.2f}s, Total: {total_time:.2f}s\")\n        return screenshot\n\n    total_time = time.time() - start\n    setup_time = total_time - capture_time\n    logger.debug(\n        f\"{watch_info}Screenshot complete - Page height: {page_height}px, Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT}px | \"\n        f\"Setup: {setup_time:.2f}s, Single chunk: {capture_time:.2f}s, Total: {total_time:.2f}s\")\n    return screenshot_chunks[0]\n\n\nclass fetcher(Fetcher):\n    fetcher_description = \"Puppeteer/direct {}/Javascript\".format(\n        os.getenv(\"PLAYWRIGHT_BROWSER_TYPE\", 'chromium').capitalize()\n    )\n    if os.getenv(\"PLAYWRIGHT_DRIVER_URL\"):\n        fetcher_description += \" via '{}'\".format(os.getenv(\"PLAYWRIGHT_DRIVER_URL\"))\n\n    browser = None\n    browser_type = ''\n    command_executor = ''\n    proxy = None\n\n    # Capability flags\n    supports_browser_steps = True\n    supports_screenshots = True\n    supports_xpath_element_data = True\n\n    @classmethod\n    def get_status_icon_data(cls):\n        \"\"\"Return Chrome browser icon data for Puppeteer fetcher.\"\"\"\n        return {\n            'filename': 'google-chrome-icon.png',\n            'alt': 'Using a Chrome browser',\n            'title': 'Using a Chrome browser'\n        }\n\n    def __init__(self, proxy_override=None, custom_browser_connection_url=None, **kwargs):\n        super().__init__(**kwargs)\n\n        if custom_browser_connection_url:\n            self.browser_connection_is_custom = True\n            self.browser_connection_url = custom_browser_connection_url\n        else:\n            # Fallback to fetching from system\n            # .strip('\"') is going to save someone a lot of time when they accidently wrap the env value\n            self.browser_connection_url = os.getenv(\"PLAYWRIGHT_DRIVER_URL\", 'ws://playwright-chrome:3000').strip('\"')\n\n        # allow per-watch proxy selection override\n        # @todo check global too?\n        if proxy_override:\n            # Playwright needs separate username and password values\n            parsed = urlparse(proxy_override)\n            if parsed:\n                self.proxy = {'username': parsed.username, 'password': parsed.password}\n                # Add the proxy server chrome start option, the username and password never gets added here\n                # (It always goes in via await self.page.authenticate(self.proxy))\n\n                # @todo filter some injection attack?\n                # check scheme when no scheme\n                proxy_url = parsed.scheme + \"://\" if parsed.scheme else 'http://'\n                r = \"?\" if not '?' in self.browser_connection_url else '&'\n                port = \":\"+str(parsed.port) if parsed.port else ''\n                q = \"?\"+parsed.query if parsed.query else ''\n                proxy_url += f\"{parsed.hostname}{port}{parsed.path}{q}\"\n                self.browser_connection_url += f\"{r}--proxy-server={proxy_url}\"\n\n    async def quit(self, watch=None):\n        watch_uuid = watch.get('uuid') if watch else 'unknown'\n\n        # Close page\n        try:\n            if hasattr(self, 'page') and self.page:\n                await asyncio.wait_for(self.page.close(), timeout=5.0)\n                logger.debug(f\"[{watch_uuid}] Page closed successfully\")\n        except asyncio.TimeoutError:\n            logger.warning(f\"[{watch_uuid}] Timed out closing page (5s)\")\n        except Exception as e:\n            logger.warning(f\"[{watch_uuid}] Error closing page: {e}\")\n        finally:\n            self.page = None\n\n        # Close browser connection\n        try:\n            if hasattr(self, 'browser') and self.browser:\n                await asyncio.wait_for(self.browser.close(), timeout=5.0)\n                logger.debug(f\"[{watch_uuid}] Browser closed successfully\")\n        except asyncio.TimeoutError:\n            logger.warning(f\"[{watch_uuid}] Timed out closing browser (5s)\")\n        except Exception as e:\n            logger.warning(f\"[{watch_uuid}] Error closing browser: {e}\")\n        finally:\n            self.browser = None\n\n        logger.info(f\"[{watch_uuid}] Cleanup puppeteer complete\")\n\n        # Force garbage collection to release resources\n        gc.collect()\n\n    async def fetch_page(self,\n                         current_include_filters,\n                         empty_pages_are_a_change,\n                         fetch_favicon,\n                         ignore_status_codes,\n                         is_binary,\n                         request_body,\n                         request_headers,\n                         request_method,\n                         screenshot_format,\n                         timeout,\n                         url,\n                         watch_uuid\n                         ):\n        import re\n        self.delete_browser_steps_screenshots()\n\n        n = int(os.getenv(\"WEBDRIVER_DELAY_BEFORE_CONTENT_READY\", 12)) + self.render_extract_delay\n        extra_wait = min(n, 15)\n\n        logger.debug(f\"Extra wait set to {extra_wait}s, requested was {n}s.\")\n\n        from pyppeteer import Pyppeteer\n        pyppeteer_instance = Pyppeteer()\n\n        # Connect directly using the specified browser_ws_endpoint\n        # @todo timeout\n        try:\n            logger.debug(f\"[{watch_uuid}] Connecting to browser at {self.browser_connection_url}\")\n            self.browser = await pyppeteer_instance.connect(browserWSEndpoint=self.browser_connection_url,\n                                                            ignoreHTTPSErrors=True\n                                                            )\n            logger.debug(f\"[{watch_uuid}] Browser connected successfully\")\n        except websockets.exceptions.InvalidStatusCode as e:\n            raise BrowserConnectError(msg=f\"Error while trying to connect the browser, Code {e.status_code} (check your access, whitelist IP, password etc)\")\n        except websockets.exceptions.InvalidURI:\n            raise BrowserConnectError(msg=f\"Error connecting to the browser, check your browser connection address (should be ws:// or wss://\")\n        except Exception as e:\n            raise BrowserConnectError(msg=f\"Error connecting to the browser - Exception '{str(e)}'\")\n\n        # more reliable is to just request a new page\n        try:\n            logger.debug(f\"[{watch_uuid}] Creating new page\")\n            self.page = await self.browser.newPage()\n            logger.debug(f\"[{watch_uuid}] Page created successfully\")\n        except Exception as e:\n            logger.error(f\"[{watch_uuid}] Failed to create new page: {e}\")\n            # Browser is connected but page creation failed - must cleanup browser\n            try:\n                await asyncio.wait_for(self.browser.close(), timeout=3.0)\n            except Exception as cleanup_error:\n                logger.error(f\"[{watch_uuid}] Failed to cleanup browser after page creation failure: {cleanup_error}\")\n            finally:\n                self.browser = None\n            raise\n        \n        # Add console handler to capture console.log from favicon fetcher\n        #self.page.on('console', lambda msg: logger.debug(f\"Browser console [{msg.type}]: {msg.text}\"))\n\n        if '--window-size' in self.browser_connection_url:\n            # Be sure the viewport is always the window-size, this is often not the same thing\n            match = re.search(r'--window-size=(\\d+),(\\d+)', self.browser_connection_url)\n            if match:\n                logger.debug(f\"Setting viewport to same as --window-size in browser connection URL {int(match.group(1))},{int(match.group(2))}\")\n                await self.page.setViewport({\n                    \"width\": int(match.group(1)),\n                    \"height\": int(match.group(2))\n                })\n                logger.debug(f\"Puppeteer viewport size {self.page.viewport}\")\n        try:\n            from pyppeteerstealth import inject_evasions_into_page\n        except ImportError:\n            logger.debug(\"pyppeteerstealth module not available, skipping\")\n            pass\n        else:\n            # I tried hooking events via self.page.on(Events.Page.DOMContentLoaded, inject_evasions_requiring_obj_to_page)\n            # But I could never get it to fire reliably, so we just inject it straight after\n            await inject_evasions_into_page(self.page)\n\n        # This user agent is similar to what was used when tweaking the evasions in inject_evasions_into_page(..)\n        user_agent = None\n        if request_headers and request_headers.get('User-Agent'):\n            # Request_headers should now be CaaseInsensitiveDict\n            # Remove it so it's not sent again with headers after\n            user_agent = request_headers.pop('User-Agent').strip()\n            await self.page.setUserAgent(user_agent)\n\n        if not user_agent:\n            # Attempt to strip 'HeadlessChrome' etc\n            await self.page.setUserAgent(manage_user_agent(headers=request_headers, current_ua=await self.page.evaluate('navigator.userAgent')))\n\n        await self.page.setBypassCSP(True)\n        if request_headers:\n            await self.page.setExtraHTTPHeaders(request_headers)\n\n        # SOCKS5 with authentication is not supported (yet)\n        # https://github.com/microsoft/playwright/issues/10567\n        self.page.setDefaultNavigationTimeout(0)\n        await self.page.setCacheEnabled(True)\n        if self.proxy and self.proxy.get('username'):\n            # Setting Proxy-Authentication header is deprecated, and doing so can trigger header change errors from Puppeteer\n            # https://github.com/puppeteer/puppeteer/issues/676 ?\n            # https://help.brightdata.com/hc/en-us/articles/12632549957649-Proxy-Manager-How-to-Guides#h_01HAKWR4Q0AFS8RZTNYWRDFJC2\n            # https://cri.dev/posts/2020-03-30-How-to-solve-Puppeteer-Chrome-Error-ERR_INVALID_ARGUMENT/\n            await self.page.authenticate(self.proxy)\n\n        # Re-use as much code from browser steps as possible so its the same\n        # from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface\n\n        # not yet used here, we fallback to playwright when browsersteps is required\n        #            browsersteps_interface = steppable_browser_interface()\n        #            browsersteps_interface.page = self.page\n\n        # Enable Network domain to detect when first bytes arrive\n        await self.page._client.send('Network.enable')\n\n        # Now set up the frame navigation handlers\n        async def handle_frame_navigation(event=None):\n            # Wait n seconds after the frameStartedLoading, not from any frameStartedLoading/frameStartedNavigating\n            logger.debug(f\"Frame navigated: {event}\")\n            w = extra_wait - 2 if extra_wait > 4 else 2\n            logger.debug(f\"Waiting {w} seconds before calling Page.stopLoading...\")\n            await asyncio.sleep(w)\n\n            # Check if page still exists (might have been closed due to error during sleep)\n            if not self.page or not hasattr(self.page, '_client'):\n                logger.debug(\"Page already closed, skipping stopLoading\")\n                return\n\n            logger.debug(\"Issuing stopLoading command...\")\n            await self.page._client.send('Page.stopLoading')\n            logger.debug(\"stopLoading command sent!\")\n\n        async def setup_frame_handlers_on_first_response(event):\n            # Only trigger for the main document response\n            if event.get('type') == 'Document':\n                logger.debug(\"First response received, setting up frame handlers for forced page stop load.\")\n                self.page._client.on('Page.frameStartedNavigating', lambda e: asyncio.create_task(handle_frame_navigation(e)))\n                self.page._client.on('Page.frameStartedLoading', lambda e: asyncio.create_task(handle_frame_navigation(e)))\n                self.page._client.on('Page.frameStoppedLoading', lambda e: logger.debug(f\"Frame stopped loading: {e}\"))\n                logger.debug(\"First response received, setting up frame handlers for forced page stop load DONE SETUP\")\n                # De-register this listener - we only need it once\n                self.page._client.remove_listener('Network.responseReceived', setup_frame_handlers_on_first_response)\n\n        # Listen for first response to trigger frame handler setup\n        self.page._client.on('Network.responseReceived', setup_frame_handlers_on_first_response)\n\n        response = None\n        attempt=0\n        while not response:\n            logger.debug(f\"Attempting page fetch {url} attempt {attempt}\")\n            asyncio.create_task(handle_frame_navigation())\n            response = await self.page.goto(url, timeout=0)\n            await asyncio.sleep(1 + extra_wait)\n            # Check if page still exists before sending command\n            if self.page and hasattr(self.page, '_client'):\n                await self.page._client.send('Page.stopLoading')\n\n            if response:\n                break\n            if not response:\n                logger.warning(\"Page did not fetch! trying again!\")\n            if response is None and attempt>=2:\n                logger.warning(f\"Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content) exiting attempt {attempt}\")\n                raise EmptyReply(url=url, status_code=None)\n            attempt+=1\n\n        self.headers = response.headers\n\n        try:\n            if self.webdriver_js_execute_code is not None and len(self.webdriver_js_execute_code):\n                await self.page.evaluate(self.webdriver_js_execute_code)\n        except Exception as e:\n            logger.warning(\"Got exception when running evaluate on custom JS code\")\n            logger.error(str(e))\n            # This can be ok, we will try to grab what we could retrieve\n            raise PageUnloadable(url=url, status_code=None, message=str(e))\n\n        try:\n            self.status_code = response.status\n        except Exception as e:\n            # https://github.com/dgtlmoon/changedetection.io/discussions/2122#discussioncomment-8241962\n            logger.critical(f\"Response from the browser/Playwright did not have a status_code! Response follows.\")\n            logger.critical(response)\n            raise PageUnloadable(url=url, status_code=None, message=str(e))\n\n        if fetch_favicon:\n            try:\n                self.favicon_blob = await self.page.evaluate(FAVICON_FETCHER_JS)\n            except Exception as e:\n                logger.error(f\"Error fetching FavIcon info {str(e)}, continuing.\")\n\n        if self.status_code != 200 and not ignore_status_codes:\n            screenshot = await capture_full_page(page=self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)\n\n            raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)\n\n        content = await self.page.content\n\n        if not empty_pages_are_a_change and len(content.strip()) == 0:\n            logger.error(\"Content Fetcher > Content was empty (empty_pages_are_a_change is False), closing browsers\")\n            raise EmptyReply(url=url, status_code=response.status)\n\n        # Run Browser Steps here\n        # @todo not yet supported, we switch to playwright in this case\n        #            if self.browser_steps:\n        #                self.iterate_browser_steps()\n\n\n        # So we can find an element on the page where its selector was entered manually (maybe not xPath etc)\n        # Setup the xPath/VisualSelector scraper\n        if current_include_filters:\n            js = json.dumps(current_include_filters)\n            await self.page.evaluate(f\"var include_filters={js}\")\n        else:\n            await self.page.evaluate(f\"var include_filters=''\")\n\n        MAX_TOTAL_HEIGHT = int(os.getenv(\"SCREENSHOT_MAX_HEIGHT\", SCREENSHOT_MAX_HEIGHT_DEFAULT))\n\n        self.content = await self.page.content\n\n        # Now take screenshot (scrolling may trigger layout changes, but measurements are already captured)\n        logger.debug(f\"Screenshot format {self.screenshot_format}\")\n        self.screenshot = await capture_full_page(page=self.page, screenshot_format=self.screenshot_format, watch_uuid=watch_uuid, lock_viewport_elements=self.lock_viewport_elements)\n\n        # Force garbage collection - pyppeteer base64 decode creates temporary buffers\n        import gc\n        gc.collect()\n        self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {\n            \"visualselector_xpath_selectors\": visualselector_xpath_selectors,\n            \"max_height\": MAX_TOTAL_HEIGHT\n        })\n        if not self.xpath_data:\n            raise Exception(f\"Content Fetcher > xPath scraper failed. Please report this URL so we can fix it :)\")\n\n\n        self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)\n\n        # It's good to log here in the case that the browser crashes on shutting down but we still get the data we need\n        logger.success(f\"Fetching '{url}' complete, exiting puppeteer fetch.\")\n\n    async def main(self, **kwargs):\n        await self.fetch_page(**kwargs)\n\n    async def run(self,\n                  fetch_favicon=True,\n                  current_include_filters=None,\n                  empty_pages_are_a_change=False,\n                  ignore_status_codes=False,\n                  is_binary=False,\n                  request_body=None,\n                  request_headers=None,\n                  request_method=None,\n                  screenshot_format=None,\n                  timeout=None,\n                  url=None,\n                  watch_uuid=None,\n                  ):\n\n        #@todo make update_worker async which could run any of these content_fetchers within memory and time constraints\n        max_time = int(os.getenv('PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS', 180))\n\n        # Now we run this properly in async context since we're called from async worker\n        try:\n            await asyncio.wait_for(self.main(\n                current_include_filters=current_include_filters,\n                empty_pages_are_a_change=empty_pages_are_a_change,\n                fetch_favicon=fetch_favicon,\n                ignore_status_codes=ignore_status_codes,\n                is_binary=is_binary,\n                request_body=request_body,\n                request_headers=request_headers,\n                request_method=request_method,\n                screenshot_format=None,\n                timeout=timeout,\n                url=url,\n                watch_uuid=watch_uuid,\n            ), timeout=max_time\n            )\n        except asyncio.TimeoutError:\n            raise (BrowserFetchTimedOut(msg=f\"Browser connected but was unable to process the page in {max_time} seconds.\"))\n        finally:\n            # Internal cleanup on any exception/timeout - call quit() immediately\n            # This prevents connection leaks during exception bursts\n            # Worker.py's quit() call becomes a redundant safety net (idempotent)\n            try:\n                await self.quit(watch={'uuid': watch_uuid} if watch_uuid else None)\n            except Exception as cleanup_error:\n                logger.error(f\"[{watch_uuid}] Error during internal quit() cleanup: {cleanup_error}\")\n\n\n# Plugin registration for built-in fetcher\nclass PuppeteerFetcherPlugin:\n    \"\"\"Plugin class that registers the Puppeteer fetcher as a built-in plugin.\"\"\"\n\n    def register_content_fetcher(self):\n        \"\"\"Register the Puppeteer fetcher\"\"\"\n        return ('html_webdriver', fetcher)\n\n\n# Create module-level instance for plugin registration\npuppeteer_plugin = PuppeteerFetcherPlugin()\n"
  },
  {
    "path": "changedetectionio/content_fetchers/requests.py",
    "content": "from loguru import logger\nfrom urllib.parse import urljoin, urlparse\nimport hashlib\nimport os\nimport re\nimport asyncio\n\nfrom changedetectionio import strtobool\nfrom changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived\nfrom changedetectionio.content_fetchers.base import Fetcher\nfrom changedetectionio.validate_url import is_private_hostname\n\n\n# \"html_requests\" is listed as the default fetcher in store.py!\nclass fetcher(Fetcher):\n    fetcher_description = \"Basic fast Plaintext/HTTP Client\"\n\n    def __init__(self, proxy_override=None, custom_browser_connection_url=None, **kwargs):\n        super().__init__(**kwargs)\n        self.proxy_override = proxy_override\n        # browser_connection_url is none because its always 'launched locally'\n\n    def _run_sync(self,\n            url,\n            timeout,\n            request_headers,\n            request_body,\n            request_method,\n            ignore_status_codes=False,\n            current_include_filters=None,\n            is_binary=False,\n            empty_pages_are_a_change=False,\n            watch_uuid=None,\n            ):\n        \"\"\"Synchronous version of run - the original requests implementation\"\"\"\n\n        import chardet\n        import requests\n        from requests.exceptions import ProxyError, ConnectionError, RequestException\n\n        if self.browser_steps:\n            raise BrowserStepsInUnsupportedFetcher(url=url)\n\n        proxies = {}\n\n        # Allows override the proxy on a per-request basis\n        # https://requests.readthedocs.io/en/latest/user/advanced/#socks\n        # Should also work with `socks5://user:pass@host:port` type syntax.\n\n        if self.proxy_override:\n            proxies = {'http': self.proxy_override, 'https': self.proxy_override, 'ftp': self.proxy_override}\n        else:\n            if self.system_http_proxy:\n                proxies['http'] = self.system_http_proxy\n            if self.system_https_proxy:\n                proxies['https'] = self.system_https_proxy\n\n        session = requests.Session()\n\n        # Configure retry adapter for low-level network errors only\n        # Retries connection timeouts, read timeouts, connection resets - not HTTP status codes\n        # Especially helpful in parallel test execution when servers are slow/overloaded\n        # Configurable via REQUESTS_RETRY_MAX_COUNT (default: 3 attempts)\n        from requests.adapters import HTTPAdapter\n        from urllib3.util.retry import Retry\n\n        max_retries = int(os.getenv(\"REQUESTS_RETRY_MAX_COUNT\", \"6\"))\n        retry_strategy = Retry(\n            total=max_retries,\n            connect=max_retries,  # Retry connection timeouts\n            read=max_retries,     # Retry read timeouts\n            status=0,             # Don't retry on HTTP status codes\n            backoff_factor=0.5,   # Wait 0.3s, 0.6s, 1.2s between retries\n            allowed_methods=[\"HEAD\", \"GET\", \"OPTIONS\", \"POST\"],\n            raise_on_status=False\n        )\n        adapter = HTTPAdapter(max_retries=retry_strategy)\n        session.mount(\"http://\", adapter)\n        session.mount(\"https://\", adapter)\n\n        if strtobool(os.getenv('ALLOW_FILE_URI', 'false')) and url.startswith('file://'):\n            from requests_file import FileAdapter\n            session.mount('file://', FileAdapter())\n\n        allow_iana_restricted = strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false'))\n\n        try:\n            # Fresh DNS check at fetch time — catches DNS rebinding regardless of add-time cache.\n            if not allow_iana_restricted:\n                parsed_initial = urlparse(url)\n                if parsed_initial.hostname and is_private_hostname(parsed_initial.hostname):\n                    raise Exception(f\"Fetch blocked: '{url}' resolves to a private/reserved IP address. \"\n                                    f\"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.\")\n\n            r = session.request(method=request_method,\n                                data=request_body.encode('utf-8') if type(request_body) is str else request_body,\n                                url=url,\n                                headers=request_headers,\n                                timeout=timeout,\n                                proxies=proxies,\n                                verify=False,\n                                allow_redirects=False)\n\n            # Manually follow redirects so each hop's resolved IP can be validated,\n            # preventing SSRF via an open redirect on a public host.\n            current_url = url\n            for _ in range(10):\n                if not r.is_redirect:\n                    break\n                location = r.headers.get('Location', '')\n                redirect_url = urljoin(current_url, location)\n                if not allow_iana_restricted:\n                    parsed_redirect = urlparse(redirect_url)\n                    if parsed_redirect.hostname and is_private_hostname(parsed_redirect.hostname):\n                        raise Exception(f\"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address.\")\n                current_url = redirect_url\n                r = session.request('GET', redirect_url,\n                                    headers=request_headers,\n                                    timeout=timeout,\n                                    proxies=proxies,\n                                    verify=False,\n                                    allow_redirects=False)\n            else:\n                raise Exception(\"Too many redirects\")\n\n        except Exception as e:\n            msg = str(e)\n            if proxies and 'SOCKSHTTPSConnectionPool' in msg:\n                msg = f\"Proxy connection failed? {msg}\"\n            raise Exception(msg) from e\n\n        # If the response did not tell us what encoding format to expect, Then use chardet to override what `requests` thinks.\n        # For example - some sites don't tell us it's utf-8, but return utf-8 content\n        # This seems to not occur when using webdriver/selenium, it seems to detect the text encoding more reliably.\n        # https://github.com/psf/requests/issues/1604 good info about requests encoding detection\n        if not is_binary:\n            # Don't run this for PDF (and requests identified as binary) takes a _long_ time\n            if not r.headers.get('content-type') or not 'charset=' in r.headers.get('content-type'):\n                # For XML/RSS feeds, check the XML declaration for encoding attribute\n                # This is more reliable than chardet which can misdetect UTF-8 as MacRoman\n                content_type = r.headers.get('content-type', '').lower()\n                if 'xml' in content_type or 'rss' in content_type:\n                    # Look for <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n                    xml_encoding_match = re.search(rb'<\\?xml[^>]+encoding=[\"\\']([^\"\\']+)[\"\\']', r.content[:200])\n                    if xml_encoding_match:\n                        r.encoding = xml_encoding_match.group(1).decode('ascii')\n                    else:\n                        # Default to UTF-8 for XML if no encoding found\n                        r.encoding = 'utf-8'\n                else:\n                    # No charset in HTTP header - sniff encoding in priority order matching browsers\n                    # (WHATWG encoding sniffing algorithm):\n                    # 1. BOM - highest confidence, check before anything else\n                    # 2. <meta charset> in first 2kb\n                    # 3. chardet statistical detection - last resort\n                    # See: https://github.com/dgtlmoon/changedetection.io/issues/3952\n                    boms = [\n                        (b'\\xef\\xbb\\xbf', 'utf-8-sig'),\n                        (b'\\xff\\xfe', 'utf-16-le'),\n                        (b'\\xfe\\xff', 'utf-16-be'),\n                    ]\n                    bom_encoding = next((enc for bom, enc in boms if r.content.startswith(bom)), None)\n                    if bom_encoding:\n                        logger.info(f\"URL: {url} Using encoding '{bom_encoding}' detected from BOM\")\n                        r.encoding = bom_encoding\n                    else:\n                        meta_charset_match = re.search(rb'<meta[^>]+charset\\s*=\\s*[\"\\']?\\s*([^\"\\'\\s;>]+)', r.content[:2000], re.IGNORECASE)\n                        if meta_charset_match:\n                            encoding = meta_charset_match.group(1).decode('ascii', errors='ignore')\n                            logger.info(f\"URL: {url} No content-type encoding in HTTP headers - Using encoding '{encoding}' from HTML meta charset tag\")\n                            r.encoding = encoding\n                        else:\n                            encoding = chardet.detect(r.content)['encoding']\n                            logger.warning(f\"URL: {url} No charset in headers or meta tag, guessed encoding as '{encoding}' via chardet\")\n                            if encoding:\n                                r.encoding = encoding\n\n        self.headers = r.headers\n\n        if not r.content or not len(r.content):\n            logger.debug(f\"Requests returned empty content for '{url}'\")\n            if not empty_pages_are_a_change:\n                raise EmptyReply(url=url, status_code=r.status_code)\n            else:\n                logger.debug(f\"URL {url} gave zero byte content reply with Status Code {r.status_code}, but empty_pages_are_a_change = True\")\n\n        # @todo test this\n        # @todo maybe you really want to test zero-byte return pages?\n        if r.status_code != 200 and not ignore_status_codes:\n            # maybe check with content works?\n            raise Non200ErrorCodeReceived(url=url, status_code=r.status_code, page_html=r.text)\n\n        self.status_code = r.status_code\n        if is_binary:\n            # Binary files just return their checksum until we add something smarter\n            self.content = hashlib.md5(r.content).hexdigest()\n        else:\n            self.content = r.text\n\n        self.raw_content = r.content\n\n        # If the content is an image, set it as screenshot for SSIM/visual comparison\n        content_type = r.headers.get('content-type', '').lower()\n        if 'image/' in content_type:\n            self.screenshot = r.content\n            logger.debug(f\"Image content detected ({content_type}), set as screenshot for comparison\")\n\n    async def run(self,\n                  fetch_favicon=True,\n                  current_include_filters=None,\n                  empty_pages_are_a_change=False,\n                  ignore_status_codes=False,\n                  is_binary=False,\n                  request_body=None,\n                  request_headers=None,\n                  request_method=None,\n                  screenshot_format=None,\n                  timeout=None,\n                  url=None,\n                  watch_uuid=None,\n                  ):\n        \"\"\"Async wrapper that runs the synchronous requests code in a thread pool\"\"\"\n\n        loop = asyncio.get_event_loop()\n\n        # Run the synchronous _run_sync in a thread pool to avoid blocking the event loop\n        # Retry logic is handled by requests' HTTPAdapter (see _run_sync for configuration)\n        await loop.run_in_executor(\n            None,  # Use default ThreadPoolExecutor\n            lambda: self._run_sync(\n                url=url,\n                timeout=timeout,\n                request_headers=request_headers,\n                request_body=request_body,\n                request_method=request_method,\n                ignore_status_codes=ignore_status_codes,\n                current_include_filters=current_include_filters,\n                is_binary=is_binary,\n                empty_pages_are_a_change=empty_pages_are_a_change,\n                watch_uuid=watch_uuid,\n            )\n        )\n\n    async def quit(self, watch=None):\n        # In case they switched to `requests` fetcher from something else\n        # Then the screenshot could be old, in any case, it's not used here.\n        # REMOVE_REQUESTS_OLD_SCREENSHOTS - Mainly used for testing\n        if strtobool(os.getenv(\"REMOVE_REQUESTS_OLD_SCREENSHOTS\", 'true')):\n            screenshot = watch.get_screenshot()\n            if screenshot:\n                try:\n                    os.unlink(screenshot)\n                except Exception as e:\n                    logger.warning(f\"Failed to unlink screenshot: {screenshot} - {e}\")\n\n\n# Plugin registration for built-in fetcher\nclass RequestsFetcherPlugin:\n    \"\"\"Plugin class that registers the requests fetcher as a built-in plugin.\"\"\"\n\n    def register_content_fetcher(self):\n        \"\"\"Register the requests fetcher\"\"\"\n        return ('html_requests', fetcher)\n\n\n# Create module-level instance for plugin registration\nrequests_plugin = RequestsFetcherPlugin()\n"
  },
  {
    "path": "changedetectionio/content_fetchers/res/__init__.py",
    "content": "# resources for browser injection/scraping\n"
  },
  {
    "path": "changedetectionio/content_fetchers/res/favicon-fetcher.js",
    "content": "(async () => {\n  // Define the function inside the IIFE for console testing\n  window.getFaviconAsBlob = async function() {\n    const links = Array.from(document.querySelectorAll(\n      'link[rel~=\"apple-touch-icon\"], link[rel~=\"icon\"]'\n    ));\n\n    const icons = links.map(link => {\n      const sizesStr = link.getAttribute('sizes');\n      let size = 0;\n      if (sizesStr) {\n        const [w] = sizesStr.split('x').map(Number);\n        if (!isNaN(w)) size = w;\n      } else {\n        size = 16;\n      }\n      return {\n        size,\n        rel: link.getAttribute('rel'),\n        href: link.href,\n        hasSizes: !!sizesStr\n      };\n    });\n\n    // If no icons found, add fallback favicon.ico\n    if (icons.length === 0) {\n      icons.push({\n        size: 16,\n        rel: 'icon',\n        href: '/favicon.ico',\n        hasSizes: false\n      });\n    }\n\n    // sort preference: highest resolution first, then apple-touch-icon, then regular icons\n    icons.sort((a, b) => {\n      // First priority: actual size (highest first)\n      if (a.size !== b.size) {\n        return b.size - a.size;\n      }\n      \n      // Second priority: apple-touch-icon over regular icon\n      const isAppleA = /apple-touch-icon/.test(a.rel);\n      const isAppleB = /apple-touch-icon/.test(b.rel);\n      if (isAppleA && !isAppleB) return -1;\n      if (!isAppleA && isAppleB) return 1;\n      \n      // Third priority: icons with no size attribute (fallback icons) last\n      const hasNoSizeA = !a.hasSizes;\n      const hasNoSizeB = !b.hasSizes;\n      if (hasNoSizeA && !hasNoSizeB) return 1;\n      if (!hasNoSizeA && hasNoSizeB) return -1;\n      \n      return 0;\n    });\n\n    const timeoutMs = 2000;\n\n    for (const icon of icons) {\n      try {\n        const controller = new AbortController();\n        const timeout = setTimeout(() => controller.abort(), timeoutMs);\n\n        const resp = await fetch(icon.href, {\n          signal: controller.signal,\n          redirect: 'follow'\n        });\n\n        clearTimeout(timeout);\n\n        if (!resp.ok) {\n          continue;\n        }\n\n        const blob = await resp.blob();\n\n        // Convert blob to base64\n        const reader = new FileReader();\n        return await new Promise(resolve => {\n          reader.onloadend = () => {\n            resolve({\n              url: icon.href,\n              base64: reader.result.split(\",\")[1]\n            });\n          };\n          reader.readAsDataURL(blob);\n        });\n\n      } catch (e) {\n        continue;\n      }\n    }\n\n    // nothing found\n    return null;\n  };\n\n  // Auto-execute and return result for page.evaluate()\n  return await window.getFaviconAsBlob();\n})();\n\n"
  },
  {
    "path": "changedetectionio/content_fetchers/res/lock-elements-sizing.js",
    "content": "/**\n * Lock Element Dimensions for Screenshot Capture (First Viewport Only)\n *\n * THE PROBLEM:\n * When taking full-page screenshots of tall pages, Chrome/Puppeteer/Playwright need to:\n * 1. Temporarily change the viewport height to a large value (e.g., 800px → 3809px)\n * 2. Take screenshots in chunks while scrolling\n * 3. Stitch the chunks together\n *\n * However, changing the viewport height triggers CSS media queries like:\n *   @media (min-height: 860px) { .ad { height: 250px; } }\n *\n * This causes elements (especially ads/headers) to resize during screenshot capture.\n *\n * THE SOLUTION:\n * Lock element dimensions in the FIRST VIEWPORT ONLY with !important inline styles.\n * This prevents headers, navigation, and top ads from resizing when viewport changes.\n * We only lock the visible portion because:\n * - Most layout shifts happen in headers/navbars/top ads\n * - Locking only visible elements is 100x+ faster (100-200 elements vs 10,000+)\n * - Below-fold content shifts don't affect visual comparison accuracy\n *\n * WHAT THIS SCRIPT DOES:\n * 1. Gets current viewport height\n * 2. Finds elements within first viewport (top of page to bottom of screen)\n * 3. Locks their dimensions with !important inline styles\n * 4. Disables ResizeObserver API (for JS-based resizing)\n *\n * USAGE:\n * Execute this script BEFORE calling capture_full_page() / screenshot functions.\n * Only enabled for image_ssim_diff processor (visual comparison).\n * Default: OFF for performance.\n *\n * PERFORMANCE:\n * - Only processes 100-300 elements (first viewport) vs 10,000+ (entire page)\n * - Typically completes in 10-50ms\n * - 100x+ faster than locking entire page\n *\n * @see https://github.com/dgtlmoon/changedetection.io/issues/XXXX\n */\n\n(() => {\n    // Store original styles in a global WeakMap for later restoration\n    window.__elementSizingRestore = new WeakMap();\n\n    const start = performance.now();\n\n    // Get current viewport height (visible portion of page)\n    const viewportHeight = window.innerHeight;\n\n    // Get all elements and filter to FIRST VIEWPORT ONLY\n    // This dramatically reduces elements to process (100-300 vs 10,000+)\n    const allElements = Array.from(document.querySelectorAll('*'));\n\n    // BATCH READ PHASE: Get bounding rects and filter to viewport\n    const measurements = allElements.map(el => {\n        const rect = el.getBoundingClientRect();\n        const computed = window.getComputedStyle(el);\n\n        // Only lock elements in the first viewport (visible on initial page load)\n        // rect.top < viewportHeight means element starts within visible area\n        const inViewport = rect.top < viewportHeight && rect.top >= 0;\n        const hasSize = rect.height > 0 && rect.width > 0;\n\n        return inViewport && hasSize ? { el, computed, rect } : null;\n    }).filter(Boolean);  // Remove null entries\n\n    const elapsed = performance.now() - start;\n    console.log(`Locked first viewport elements: ${measurements.length} of ${allElements.length} total elements (viewport height: ${viewportHeight}px, took ${elapsed.toFixed(0)}ms)`);\n\n    // BATCH WRITE PHASE: Apply all inline styles without triggering layout\n    // No interleaved reads means browser can optimize style application\n    measurements.forEach(({el, computed, rect}) => {\n        // Save original inline style values BEFORE locking\n        const properties = ['height', 'min-height', 'max-height', 'width', 'min-width', 'max-width'];\n        const originalStyles = {};\n        properties.forEach(prop => {\n            originalStyles[prop] = {\n                value: el.style.getPropertyValue(prop),\n                priority: el.style.getPropertyPriority(prop)\n            };\n        });\n        window.__elementSizingRestore.set(el, originalStyles);\n\n        // Lock dimensions with !important to override media queries\n        if (rect.height > 0) {\n            el.style.setProperty('height', computed.height, 'important');\n            el.style.setProperty('min-height', computed.height, 'important');\n            el.style.setProperty('max-height', computed.height, 'important');\n        }\n        if (rect.width > 0) {\n            el.style.setProperty('width', computed.width, 'important');\n            el.style.setProperty('min-width', computed.width, 'important');\n            el.style.setProperty('max-width', computed.width, 'important');\n        }\n    });\n\n    // Also disable ResizeObserver for JS-based resizing\n    window.ResizeObserver = class {\n        constructor() {}\n        observe() {}\n        unobserve() {}\n        disconnect() {}\n    };\n\n    console.log(`✓ Element dimensions locked (${measurements.length} elements) to prevent media query changes during screenshot`);\n})();\n"
  },
  {
    "path": "changedetectionio/content_fetchers/res/stock-not-in-stock.js",
    "content": "async () => {\n\n    function isItemInStock() {\n        // @todo Pass these in so the same list can be used in non-JS fetchers\n        const outOfStockTexts = [\n            ' أخبرني عندما يتوفر',\n            '0 in stock',\n            'actuellement indisponible',\n            'agotado',\n            'article épuisé',\n            'artikel zurzeit vergriffen',\n            'as soon as stock is available',\n            'aucune offre n\\'est disponible',\n            'ausverkauft', // sold out\n            'available for back order',\n            'awaiting stock',\n            'back in stock soon',\n            'back-order or out of stock',\n            'backordered',\n            'backorder',\n            'benachrichtigt mich', // notify me\n            'binnenkort leverbaar', // coming soon\n            'brak na stanie',\n            'brak w magazynie',\n            'coming soon',\n            'currently have any tickets for this',\n            'currently unavailable',\n            'dieser artikel ist bald wieder verfügbar',\n            'dostępne wkrótce',\n            'en rupture',\n            'esgotado',\n            'in kürze lieferbar',\n            'indisponible',\n            'indisponível',\n            'isn\\'t in stock right now',\n            'isnt in stock right now',\n            'isn’t in stock right now',\n            'item is no longer available',\n            'let me know when it\\'s available',\n            'mail me when available',\n            'message if back in stock',\n            'mevcut değil',\n            'more on order',\n            'nachricht bei',\n            'nicht auf lager',\n            'nicht lagernd',\n            'nicht lieferbar',\n            'nicht verfügbar',\n            'nicht vorrätig',\n            'nicht mehr lieferbar',\n            'nicht zur verfügung',\n            'nie znaleziono produktów',\n            'niet beschikbaar',\n            'niet leverbaar',\n            'niet op voorraad',\n            'no disponible',\n            'no featured offers available',\n            'no longer available',\n            'no longer in stock',\n            'no tickets available',\n            'non disponibile',\n            'non disponible',\n            'not available',\n            'not currently available',\n            'not in stock',\n            'notify me when available',\n            'notify me',\n            'notify when available',\n            'não disponível',\n            'não estamos a aceitar encomendas',\n            'out of stock',\n            'out-of-stock',\n            'plus disponible',\n            'prodotto esaurito',\n            'produkt niedostępny',\n            'rupture',\n            'sold out',\n            'sold-out',\n            'stok habis',\n            'stok kosong',\n            'stok varian ini habis',\n            'stokta yok',\n            'temporarily out of stock',\n            'temporarily unavailable',\n            'there were no search results for',\n            'this item is currently unavailable',\n            'tickets unavailable',\n            'tidak dijual',\n            'tidak tersedia',\n            'tijdelijk uitverkocht',\n            'tiket tidak tersedia',\n            'to subscribe to back in stock',\n            'tükendi',\n            'unavailable nearby',\n            'unavailable tickets',\n            'vergriffen',\n            'vorbestellen',\n            'vorbestellung ist bald möglich',\n            'we couldn\\'t find any products that match',\n            'we do not currently have an estimate of when this product will be back in stock.',\n            'we don\\'t currently have any',\n            'we don\\'t know when or if this item will be back in stock.',\n            'we were not able to find a match',\n            'when this arrives in stock',\n            'when this item is available to order',\n            'zur zeit nicht an lager',\n            'épuisé',\n            '品切れ',\n            '已售',\n            '已售完',\n            '품절'\n        ];\n\n\n        const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);\n\n        function getElementBaseText(element) {\n            // .textContent can include text from children which may give the wrong results\n            // scan only immediate TEXT_NODEs, which will be a child of the element\n            var text = \"\";\n            for (var i = 0; i < element.childNodes.length; ++i)\n                if (element.childNodes[i].nodeType === Node.TEXT_NODE)\n                    text += element.childNodes[i].textContent;\n            return text.toLowerCase().trim();\n        }\n\n        const negateOutOfStockRegex = new RegExp('^([0-9] in stock|add to cart|in stock|arrives approximately)', 'ig');\n        // The out-of-stock or in-stock-text is generally always above-the-fold\n        // and often below-the-fold is a list of related products that may or may not contain trigger text\n        // so it's good to filter to just the 'above the fold' elements\n        // and it should be atleast 100px from the top to ignore items in the toolbar, sometimes menu items like \"Coming soon\" exist\n\n        function elementIsInEyeBallRange(element) {\n            // outside the 'fold' or some weird text in the heading area\n            // .getBoundingClientRect() was causing a crash in chrome 119, can only be run on contentVisibility != hidden\n            // Note: theres also an automated test that places the 'out of stock' text fairly low down\n            // Skip text that could be in the header area\n            if (element.getBoundingClientRect().bottom + window.scrollY <= 300 ) {\n                return false;\n            }\n            // Skip text that could be much further down (like a list of \"you may like\" products that have 'sold out' in there\n            if (element.getBoundingClientRect().bottom + window.scrollY >= 1300 ) {\n                return false;\n            }\n            return true;\n        }\n\n// @todo - if it's SVG or IMG, go into image diff mode\n\n        function collectVisibleElements(parent, visibleElements) {\n            if (!parent) return; // Base case: if parent is null or undefined, return\n\n            // Add the parent itself to the visible elements array if it's of the specified types\n            visibleElements.push(parent);\n\n            // Iterate over the parent's children\n            const children = parent.children;\n            for (let i = 0; i < children.length; i++) {\n                const child = children[i];\n                if (\n                    child.nodeType === Node.ELEMENT_NODE &&\n                    window.getComputedStyle(child).display !== 'none' &&\n                    window.getComputedStyle(child).visibility !== 'hidden' &&\n                    child.offsetWidth >= 0 &&\n                    child.offsetHeight >= 0 &&\n                    window.getComputedStyle(child).contentVisibility !== 'hidden'\n                ) {\n                    // If the child is an element and is visible, recursively collect visible elements\n                    collectVisibleElements(child, visibleElements);\n                }\n            }\n        }\n\n        const elementsToScan = [];\n        collectVisibleElements(document.body, elementsToScan);\n\n        var elementText = \"\";\n\n        // REGEXS THAT REALLY MEAN IT'S IN STOCK\n        for (let i = elementsToScan.length - 1; i >= 0; i--) {\n            const element = elementsToScan[i];\n\n            if (!elementIsInEyeBallRange(element)) {\n                continue\n            }\n\n            elementText = \"\";\n            try {\n                if (element.tagName.toLowerCase() === \"input\") {\n                    elementText = element.value.toLowerCase().trim();\n                } else {\n                    elementText = getElementBaseText(element);\n                }\n            } catch (e) {\n                console.warn('stock-not-in-stock.js scraper - handling element for gettext failed', e);\n            }\n            if (elementText.length) {\n                // try which ones could mean its in stock\n                if (negateOutOfStockRegex.test(elementText) && !elementText.includes('(0 products)')) {\n                    console.log(`Negating/overriding 'Out of Stock' back to \"Possibly in stock\" found \"${elementText}\"`)\n                    element.style.border = \"2px solid green\"; // highlight the element that was detected as in stock\n                    return 'Possibly in stock';\n                }\n            }\n        }\n\n        // OTHER STUFF THAT COULD BE THAT IT'S OUT OF STOCK\n        for (let i = elementsToScan.length - 1; i >= 0; i--) {\n            const element = elementsToScan[i];\n\n            if (!elementIsInEyeBallRange(element)) {\n                continue\n            }\n            elementText = \"\";\n            if (element.tagName.toLowerCase() === \"input\") {\n                elementText = element.value.toLowerCase().trim();\n            } else {\n                elementText = getElementBaseText(element);\n            }\n\n            if (elementText.length) {\n                // and these mean its out of stock\n                for (const outOfStockText of outOfStockTexts) {\n                    if (elementText.includes(outOfStockText)) {\n                        console.log(`Selected 'Out of Stock' - found text \"${outOfStockText}\" - \"${elementText}\" - offset top ${element.getBoundingClientRect().top}, page height is ${vh}`)\n                        element.style.border = \"2px solid red\"; // highlight the element that was detected as out of stock\n                        return outOfStockText; // item is out of stock\n                    }\n                }\n            }\n        }\n\n        console.log(`Returning 'Possibly in stock' - cant' find any useful matching text`)\n        return 'Possibly in stock'; // possibly in stock, cant decide otherwise.\n    }\n\n// returns the element text that makes it think it's out of stock\n    return isItemInStock().trim()\n}\n"
  },
  {
    "path": "changedetectionio/content_fetchers/res/unlock-elements-sizing.js",
    "content": "/**\n * Unlock Element Dimensions After Screenshot Capture\n *\n * This script removes the inline !important styles that were applied by lock-elements-sizing.js\n * and restores elements to their original state using the WeakMap created during locking.\n *\n * USAGE:\n * Execute this script AFTER completing screenshot capture and restoring the viewport.\n * This allows the page to return to its normal responsive behavior.\n *\n * WHAT THIS SCRIPT DOES:\n * 1. Iterates through every element that was locked\n * 2. Reads original style values from the global WeakMap\n * 3. Restores original inline styles (or removes them if they weren't set originally)\n * 4. Cleans up the WeakMap\n *\n * @see lock-elements-sizing.js for the locking mechanism\n */\n\n(() => {\n    // Check if the restore map exists\n    if (!window.__elementSizingRestore) {\n        console.log('⚠ Element sizing restore map not found - elements may not have been locked');\n        return;\n    }\n\n    // Restore all locked dimension styles to their original state\n    document.querySelectorAll('*').forEach(el => {\n        const originalStyles = window.__elementSizingRestore.get(el);\n\n        if (originalStyles) {\n            const properties = ['height', 'min-height', 'max-height', 'width', 'min-width', 'max-width'];\n\n            properties.forEach(prop => {\n                const original = originalStyles[prop];\n\n                if (original.value) {\n                    // Restore original value with original priority\n                    el.style.setProperty(prop, original.value, original.priority || '');\n                } else {\n                    // Was not set originally, so remove it\n                    el.style.removeProperty(prop);\n                }\n            });\n        }\n    });\n\n    // Clean up the global WeakMap\n    delete window.__elementSizingRestore;\n\n    console.log('✓ Element dimensions unlocked - page restored to original state');\n})();\n"
  },
  {
    "path": "changedetectionio/content_fetchers/res/xpath_element_scraper.js",
    "content": "async (options) => {\n\n    let visualselector_xpath_selectors = options.visualselector_xpath_selectors\n    let max_height = options.max_height\n\n    var scroll_y = 0;\n    try {\n        scroll_y = +document.documentElement.scrollTop || document.body.scrollTop\n    } catch (e) {\n        console.log(e);\n    }\n\n// Include the getXpath script directly, easier than fetching\n    function getxpath(e) {\n        var n = e;\n        if (n && n.id) return '//*[@id=\"' + n.id + '\"]';\n        for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) {\n            for (var i = 0, r = !1, d = n.previousSibling; d;) d.nodeType !== Node.DOCUMENT_TYPE_NODE && d.nodeName === n.nodeName && i++, d = d.previousSibling;\n            for (d = n.nextSibling; d;) {\n                if (d.nodeName === n.nodeName) {\n                    r = !0;\n                    break\n                }\n                d = d.nextSibling\n            }\n            o.push((n.prefix ? n.prefix + \":\" : \"\") + n.localName + (i || r ? \"[\" + (i + 1) + \"]\" : \"\")), n = n.parentNode\n        }\n        return o.length ? \"/\" + o.reverse().join(\"/\") : \"\"\n    }\n\n    const findUpTag = (el) => {\n        let r = el\n        chained_css = [];\n        depth = 0;\n\n        //  Strategy 1: If it's an input, with name, and there's only one, prefer that\n        if (el.name !== undefined && el.name.length) {\n            var proposed = el.tagName + \"[name=\\\"\" + CSS.escape(el.name) + \"\\\"]\";\n            var proposed_element = window.document.querySelectorAll(proposed);\n            if (proposed_element.length) {\n                if (proposed_element.length === 1) {\n                    return proposed;\n                } else {\n                    // Some sites change ID but name= stays the same, we can hit it if we know the index\n                    // Find all the elements that match and work out the input[n]\n                    var n = Array.from(proposed_element).indexOf(el);\n                    // Return a Playwright selector for nthinput[name=zipcode]\n                    return proposed + \" >> nth=\" + n;\n                }\n            }\n        }\n\n        // Strategy 2: Keep going up until we hit an ID tag, imagine it's like  #list-widget div h4\n        while (r.parentNode) {\n            if (depth === 5) {\n                break;\n            }\n            if ('' !== r.id) {\n                chained_css.unshift(\"#\" + CSS.escape(r.id));\n                final_selector = chained_css.join(' > ');\n                // Be sure theres only one, some sites have multiples of the same ID tag :-(\n                if (window.document.querySelectorAll(final_selector).length === 1) {\n                    return final_selector;\n                }\n                return null;\n            } else {\n                chained_css.unshift(r.tagName.toLowerCase());\n            }\n            r = r.parentNode;\n            depth += 1;\n        }\n        return null;\n    }\n\n\n// @todo - if it's SVG or IMG, go into image diff mode\n\n    var size_pos = [];\n// after page fetch, inject this JS\n// build a map of all elements and their positions (maybe that only include text?)\n    var bbox;\n    console.log(`Scanning for \"${visualselector_xpath_selectors}\"`);\n\n    function collectVisibleElements(parent, visibleElements) {\n        if (!parent) return; // Base case: if parent is null or undefined, return\n\n\n        // Add the parent itself to the visible elements array if it's of the specified types\n        const tagName = parent.tagName.toLowerCase();\n        if (visualselector_xpath_selectors.split(',').includes(tagName)) {\n            visibleElements.push(parent);\n        }\n\n        // Iterate over the parent's children\n        const children = parent.children;\n        for (let i = 0; i < children.length; i++) {\n            const child = children[i];\n            const computedStyle = window.getComputedStyle(child);\n\n            if (\n                child.nodeType === Node.ELEMENT_NODE &&\n                computedStyle.display !== 'none' &&\n                computedStyle.visibility !== 'hidden' &&\n                child.offsetWidth >= 0 &&\n                child.offsetHeight >= 0 &&\n                computedStyle.contentVisibility !== 'hidden'\n            ) {\n                // If the child is an element and is visible, recursively collect visible elements\n                collectVisibleElements(child, visibleElements);\n            }\n        }\n    }\n\n// Create an array to hold the visible elements\n    const visibleElementsArray = [];\n\n// Call collectVisibleElements with the starting parent element\n    collectVisibleElements(document.body, visibleElementsArray);\n\n\n    visibleElementsArray.forEach(function (element) {\n\n        bbox = element.getBoundingClientRect();\n\n        // Skip really small ones, and where width or height ==0\n        if (bbox['width'] * bbox['height'] < 10) {\n            return\n        }\n\n        // Don't include elements that are offset from canvas\n        if (bbox['top'] + scroll_y < 0 || bbox['left'] < 0) {\n            return\n        }\n\n        // @todo the getXpath kind of sucks, it doesnt know when there is for example just one ID sometimes\n        // it should not traverse when we know we can anchor off just an ID one level up etc..\n        // maybe, get current class or id, keep traversing up looking for only class or id until there is just one match\n\n        // 1st primitive - if it has class, try joining it all and select, if theres only one.. well thats us.\n        xpath_result = false;\n        try {\n            var d = findUpTag(element);\n            if (d) {\n                xpath_result = d;\n            }\n        } catch (e) {\n            console.log(e);\n        }\n        // You could swap it and default to getXpath and then try the smarter one\n        // default back to the less intelligent one\n        if (!xpath_result) {\n            try {\n                // I've seen on FB and eBay that this doesnt work\n                // ReferenceError: getXPath is not defined at eval (eval at evaluate (:152:29), <anonymous>:67:20) at UtilityScript.evaluate (<anonymous>:159:18) at UtilityScript.<anonymous> (<anonymous>:1:44)\n                xpath_result = getxpath(element);\n            } catch (e) {\n                console.log(e);\n                return\n            }\n        }\n\n        let label = \"not-interesting\" // A placeholder, the actual labels for training are done by hand for now\n\n        let text = element.textContent.trim().slice(0, 30).trim();\n        while (/\\n{2,}|\\t{2,}/.test(text)) {\n            text = text.replace(/\\n{2,}/g, '\\n').replace(/\\t{2,}/g, '\\t')\n        }\n\n        // Try to identify any possible currency amounts \"Sale: 4000\" or \"Sale now 3000 Kc\", can help with the training.\n        const hasDigitCurrency = (/\\d/.test(text.slice(0, 6)) || /\\d/.test(text.slice(-6))) && /([€£$¥₩₹]|USD|AUD|EUR|Kč|kr|SEK|,–)/.test(text);\n        const computedStyle = window.getComputedStyle(element);\n\n        if (Math.floor(bbox['top']) + scroll_y > max_height) {\n            return\n        }\n\n        size_pos.push({\n            xpath: xpath_result,\n            width: Math.round(bbox['width']),\n            height: Math.round(bbox['height']),\n            left: Math.floor(bbox['left']),\n            top: Math.floor(bbox['top']) + scroll_y,\n            // tagName used by Browser Steps\n            tagName: (element.tagName) ? element.tagName.toLowerCase() : '',\n            // tagtype used by Browser Steps\n            tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '',\n            isClickable: computedStyle.cursor === \"pointer\",\n            // Used by the keras trainer\n            fontSize: computedStyle.getPropertyValue('font-size'),\n            fontWeight: computedStyle.getPropertyValue('font-weight'),\n            hasDigitCurrency: hasDigitCurrency,\n            label: label,\n        });\n\n    });\n\n\n// Inject the current one set in the include_filters, which may be a CSS rule\n// used for displaying the current one in VisualSelector, where its not one we generated.\n    if (include_filters.length) {\n        let results;\n        // Foreach filter, go and find it on the page and add it to the results so we can visualise it again\n        for (const f of include_filters) {\n            bbox = false;\n\n            if (!f.length) {\n                console.log(\"xpath_element_scraper: Empty filter, skipping\");\n                continue;\n            }\n\n            try {\n                // is it xpath?\n                if (f.startsWith('/') || f.startsWith('xpath')) {\n                    var qry_f = f.replace(/xpath(:|\\d:)/, '')\n                    console.log(\"[xpath] Scanning for included filter \" + qry_f)\n                    let xpathResult = document.evaluate(qry_f, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);\n                    results = [];\n                    for (let i = 0; i < xpathResult.snapshotLength; i++) {\n                        results.push(xpathResult.snapshotItem(i));\n                    }\n                } else {\n                    console.log(\"[css] Scanning for included filter \" + f)\n                    console.log(\"[css] Scanning for included filter \" + f);\n                    results = document.querySelectorAll(f);\n                }\n            } catch (e) {\n                // Maybe catch DOMException and alert?\n                console.log(\"xpath_element_scraper: Exception selecting element from filter \" + f);\n                console.log(e);\n            }\n\n            if (results != null && results.length) {\n\n                // Iterate over the results\n                results.forEach(node => {\n                    // Try to resolve //something/text() back to its /something so we can atleast get the bounding box\n                    try {\n                        if (typeof node.nodeName == 'string' && node.nodeName === '#text') {\n                            node = node.parentElement\n                        }\n                    } catch (e) {\n                        console.log(e)\n                        console.log(\"xpath_element_scraper: #text resolver\")\n                    }\n\n                    // #1231 - IN the case XPath attribute filter is applied, we will have to traverse up and find the element.\n                    if (typeof node.getBoundingClientRect == 'function') {\n                        bbox = node.getBoundingClientRect();\n                        console.log(\"xpath_element_scraper: Got filter element, scroll from top was \" + scroll_y)\n                    } else {\n                        try {\n                            // Try and see we can find its ownerElement\n                            bbox = node.ownerElement.getBoundingClientRect();\n                            console.log(\"xpath_element_scraper: Got filter by ownerElement element, scroll from top was \" + scroll_y)\n                        } catch (e) {\n                            console.log(e)\n                            console.log(\"xpath_element_scraper: error looking up node.ownerElement\")\n                        }\n                    }\n\n                    if (bbox && bbox['width'] > 0 && bbox['height'] > 0) {\n                        size_pos.push({\n                            xpath: f,\n                            width: parseInt(bbox['width']),\n                            height: parseInt(bbox['height']),\n                            left: parseInt(bbox['left']),\n                            top: parseInt(bbox['top']) + scroll_y,\n                            highlight_as_custom_filter: true\n                        });\n                    }\n                });\n            }\n        }\n    }\n\n// Sort the elements so we find the smallest one first, in other words, we find the smallest one matching in that area\n// so that we dont select the wrapping element by mistake and be unable to select what we want\n    size_pos.sort((a, b) => (a.width * a.height > b.width * b.height) ? 1 : -1)\n\n// browser_width required for proper scaling in the frontend\n    // Return as a string to save playwright for juggling thousands of objects\n    return JSON.stringify({'size_pos': size_pos, 'browser_width': window.innerWidth});\n}\n\n"
  },
  {
    "path": "changedetectionio/content_fetchers/screenshot_handler.py",
    "content": "# Pages with a vertical height longer than this will use the 'stitch together' method.\n\n# - Many GPUs have a max texture size of 16384x16384px (or lower on older devices).\n# - If a page is taller than ~8000–10000px, it risks exceeding GPU memory limits.\n# - This is especially important on headless Chromium, where Playwright may fail to allocate a massive full-page buffer.\n\nfrom loguru import logger\n\nfrom changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, SCREENSHOT_DEFAULT_QUALITY\n\ndef stitch_images_worker_raw_bytes(pipe_conn, original_page_height, capture_height):\n    \"\"\"\n    Stitch image chunks together in a separate process.\n\n    Uses spawn multiprocessing to isolate PIL's C-level memory allocation.\n    When the subprocess exits, the OS reclaims ALL memory including C-level allocations\n    that Python's GC cannot release. This prevents the ~50MB per stitch from accumulating\n    in the main process.\n\n    Trade-off: Adds 35MB resource_tracker subprocess, but prevents 500MB+ memory leak\n    in main process (much better at scale: 35GB vs 500GB for 1000 instances).\n\n    Args:\n        pipe_conn: Pipe connection to receive data and send result\n        original_page_height: Original page height in pixels\n        capture_height: Maximum capture height\n    \"\"\"\n    import os\n    import io\n    import struct\n    from PIL import Image, ImageDraw, ImageFont\n\n    try:\n        # Receive chunk count as 4-byte integer (no pickle!)\n        count_bytes = pipe_conn.recv_bytes()\n        chunk_count = struct.unpack('I', count_bytes)[0]\n\n        # Receive each chunk as raw bytes (no pickle!)\n        chunks_bytes = []\n        for _ in range(chunk_count):\n            chunks_bytes.append(pipe_conn.recv_bytes())\n\n        # Load images from byte chunks\n        images = [Image.open(io.BytesIO(b)) for b in chunks_bytes]\n        del chunks_bytes\n\n        total_height = sum(im.height for im in images)\n        max_width = max(im.width for im in images)\n\n        # Create stitched image\n        stitched = Image.new('RGB', (max_width, total_height))\n        y_offset = 0\n        for im in images:\n            stitched.paste(im, (0, y_offset))\n            y_offset += im.height\n            im.close()\n        del images\n\n        # Draw caption only if page was trimmed\n        if original_page_height > capture_height:\n            draw = ImageDraw.Draw(stitched)\n            caption_text = f\"WARNING: Screenshot was {original_page_height}px but trimmed to {capture_height}px because it was too long\"\n            padding = 10\n            try:\n                font = ImageFont.truetype(\"arial.ttf\", 35)\n            except IOError:\n                font = ImageFont.load_default()\n\n            bbox = draw.textbbox((0, 0), caption_text, font=font)\n            text_width = bbox[2] - bbox[0]\n            text_height = bbox[3] - bbox[1]\n            draw.rectangle([(0, 0), (max_width, text_height + 2 * padding)], fill=(255, 255, 255))\n            text_x = (max_width - text_width) // 2\n            draw.text((text_x, padding), caption_text, font=font, fill=(255, 0, 0))\n\n        # Encode and send\n        output = io.BytesIO()\n        stitched.save(output, format=\"JPEG\", quality=int(os.getenv(\"SCREENSHOT_QUALITY\", SCREENSHOT_DEFAULT_QUALITY)), optimize=True)\n        result_bytes = output.getvalue()\n\n        stitched.close()\n        del stitched\n        output.close()\n        del output\n\n        pipe_conn.send_bytes(result_bytes)\n        del result_bytes\n\n    except Exception as e:\n        logger.error(f\"Error in stitch_images_worker_raw_bytes: {e}\")\n        error_msg = f\"error:{e}\".encode('utf-8')\n        pipe_conn.send_bytes(error_msg)\n    finally:\n        pipe_conn.close()\n"
  },
  {
    "path": "changedetectionio/content_fetchers/webdriver_selenium.py",
    "content": "import os\nimport time\n\nfrom loguru import logger\nfrom changedetectionio.content_fetchers.base import Fetcher\n\n\nclass fetcher(Fetcher):\n    if os.getenv(\"WEBDRIVER_URL\"):\n        fetcher_description = f\"WebDriver Chrome/Javascript via \\\"{os.getenv('WEBDRIVER_URL', '')}\\\"\"\n    else:\n        fetcher_description = \"WebDriver Chrome/Javascript\"\n\n    proxy = None\n    proxy_url = None\n\n    # Capability flags\n    supports_browser_steps = False\n    supports_screenshots = True\n    supports_xpath_element_data = True\n\n    @classmethod\n    def get_status_icon_data(cls):\n        \"\"\"Return Chrome browser icon data for WebDriver fetcher.\"\"\"\n        return {\n            'filename': 'google-chrome-icon.png',\n            'alt': 'Using a Chrome browser',\n            'title': 'Using a Chrome browser'\n        }\n\n    def __init__(self, proxy_override=None, custom_browser_connection_url=None, **kwargs):\n        super().__init__(**kwargs)\n        from urllib.parse import urlparse\n        from selenium.webdriver.common.proxy import Proxy\n\n        # .strip('\"') is going to save someone a lot of time when they accidently wrap the env value\n        if not custom_browser_connection_url:\n            self.browser_connection_url = os.getenv(\"WEBDRIVER_URL\", 'http://browser-chrome:4444/wd/hub').strip('\"')\n        else:\n            self.browser_connection_is_custom = True\n            self.browser_connection_url = custom_browser_connection_url\n\n        ##### PROXY SETUP #####\n\n        proxy_sources = [\n            self.system_http_proxy,\n            self.system_https_proxy,\n            os.getenv('webdriver_proxySocks'),\n            os.getenv('webdriver_socksProxy'),\n            os.getenv('webdriver_proxyHttp'),\n            os.getenv('webdriver_httpProxy'),\n            os.getenv('webdriver_proxyHttps'),\n            os.getenv('webdriver_httpsProxy'),\n            os.getenv('webdriver_sslProxy'),\n            proxy_override,  # last one should override\n        ]\n        # The built in selenium proxy handling is super unreliable!!! so we just grab which ever proxy setting we can find and throw it in --proxy-server=\n        for k in filter(None, proxy_sources):\n            if not k:\n                continue\n            self.proxy_url = k.strip()\n\n    async def run(self,\n                  fetch_favicon=True,\n                  current_include_filters=None,\n                  empty_pages_are_a_change=False,\n                  ignore_status_codes=False,\n                  is_binary=False,\n                  request_body=None,\n                  request_headers=None,\n                  request_method=None,\n                  screenshot_format=None,\n                  timeout=None,\n                  url=None,\n                  watch_uuid=None,\n                  ):\n\n        import asyncio\n\n        # Wrap the entire selenium operation in a thread executor\n        def _run_sync():\n            from selenium.webdriver.chrome.options import Options as ChromeOptions\n            # request_body, request_method unused for now, until some magic in the future happens.\n\n            options = ChromeOptions()\n\n            # Load Chrome options from env\n            CHROME_OPTIONS = [\n                line.strip()\n                for line in os.getenv(\"CHROME_OPTIONS\", \"\").strip().splitlines()\n                if line.strip()\n            ]\n\n            for opt in CHROME_OPTIONS:\n                options.add_argument(opt)\n\n            # 1. proxy_config /Proxy(proxy_config) selenium object is REALLY unreliable\n            # 2. selenium-wire cant be used because the websocket version conflicts with pypeteer-ng\n            # 3. selenium only allows ONE runner at a time by default!\n            # 4. driver must use quit() or it will continue to block/hold the selenium process!!\n\n            if self.proxy_url:\n                options.add_argument(f'--proxy-server={self.proxy_url}')\n\n            from selenium.webdriver.remote.remote_connection import RemoteConnection\n            from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver\n            driver = None\n            try:\n                # Create the RemoteConnection and set timeout (e.g., 30 seconds)\n                remote_connection = RemoteConnection(\n                    self.browser_connection_url,\n                )\n                remote_connection.set_timeout(30)  # seconds\n\n                # Now create the driver with the RemoteConnection\n                driver = RemoteWebDriver(\n                    command_executor=remote_connection,\n                    options=options\n                )\n\n                driver.set_page_load_timeout(int(os.getenv(\"WEBDRIVER_PAGELOAD_TIMEOUT\", 45)))\n            except Exception as e:\n                if driver:\n                    driver.quit()\n                raise e\n\n            try:\n                driver.get(url)\n\n                if not \"--window-size\" in os.getenv(\"CHROME_OPTIONS\", \"\"):\n                    driver.set_window_size(1280, 1024)\n\n                driver.implicitly_wait(int(os.getenv(\"WEBDRIVER_DELAY_BEFORE_CONTENT_READY\", 5)))\n\n                if self.webdriver_js_execute_code is not None:\n                    driver.execute_script(self.webdriver_js_execute_code)\n                    # Selenium doesn't automatically wait for actions as good as Playwright, so wait again\n                    driver.implicitly_wait(int(os.getenv(\"WEBDRIVER_DELAY_BEFORE_CONTENT_READY\", 5)))\n\n                # @todo - how to check this? is it possible?\n                self.status_code = 200\n                # @todo somehow we should try to get this working for WebDriver\n                # raise EmptyReply(url=url, status_code=r.status_code)\n\n                # @todo - dom wait loaded?\n                import time\n                time.sleep(int(os.getenv(\"WEBDRIVER_DELAY_BEFORE_CONTENT_READY\", 5)) + self.render_extract_delay)\n                self.content = driver.page_source\n                self.headers = {}\n\n                # Selenium always captures as PNG, convert to JPEG if needed\n                screenshot_png = driver.get_screenshot_as_png()\n\n                # Convert to JPEG if requested (for smaller file size)\n                if self.screenshot_format and self.screenshot_format.upper() == 'JPEG':\n                    from PIL import Image\n                    import io\n                    img = Image.open(io.BytesIO(screenshot_png))\n                    # Convert to RGB if needed (JPEG doesn't support transparency)\n                    # Always convert non-RGB modes to RGB to ensure JPEG compatibility\n                    if img.mode in ('RGBA', 'LA', 'P', 'PA'):\n                        # Handle transparency by compositing onto white background\n                        if img.mode == 'P':\n                            img = img.convert('RGBA')\n                        background = Image.new('RGB', img.size, (255, 255, 255))\n                        if img.mode in ('RGBA', 'LA', 'PA'):\n                            background.paste(img, mask=img.split()[-1])  # Use alpha channel as mask\n                        img = background\n                    elif img.mode != 'RGB':\n                        # For other modes, direct conversion\n                        img = img.convert('RGB')\n                    jpeg_buffer = io.BytesIO()\n                    img.save(jpeg_buffer, format='JPEG', quality=int(os.getenv(\"SCREENSHOT_QUALITY\", 72)))\n                    self.screenshot = jpeg_buffer.getvalue()\n                    img.close()\n                else:\n                    self.screenshot = screenshot_png\n            except Exception as e:\n                driver.quit()\n                raise e\n\n            driver.quit()\n\n        # Run the selenium operations in a thread pool to avoid blocking the event loop\n        loop = asyncio.get_event_loop()\n        await loop.run_in_executor(None, _run_sync)\n\n\n# Plugin registration for built-in fetcher\nclass WebDriverSeleniumFetcherPlugin:\n    \"\"\"Plugin class that registers the WebDriver Selenium fetcher as a built-in plugin.\"\"\"\n\n    def register_content_fetcher(self):\n        \"\"\"Register the WebDriver Selenium fetcher\"\"\"\n        return ('html_webdriver', fetcher)\n\n\n# Create module-level instance for plugin registration\nwebdriver_selenium_plugin = WebDriverSeleniumFetcherPlugin()\n"
  },
  {
    "path": "changedetectionio/custom_queue.py",
    "content": "import queue\nimport asyncio\nfrom blinker import signal\nfrom loguru import logger\n\n\nclass NotificationQueue(queue.Queue):\n    \"\"\"\n    Extended Queue that sends a 'notification_event' signal when notifications are added.\n    \n    This class extends the standard Queue and adds a signal emission after a notification\n    is put into the queue. The signal includes the watch UUID if available.\n    \"\"\"\n    \n    def __init__(self, maxsize=0):\n        super().__init__(maxsize)\n        try:\n            self.notification_event_signal = signal('notification_event')\n        except Exception as e:\n            logger.critical(f\"Exception creating notification_event signal: {e}\")\n\n    def put(self, item, block=True, timeout=None):\n        # Call the parent's put method first\n        super().put(item, block, timeout)\n        \n        # After putting the notification in the queue, emit signal with watch UUID\n        try:\n            if self.notification_event_signal and isinstance(item, dict):\n                watch_uuid = item.get('uuid')\n                if watch_uuid:\n                    # Send the notification_event signal with the watch UUID\n                    self.notification_event_signal.send(watch_uuid=watch_uuid)\n                    logger.trace(f\"NotificationQueue: Emitted notification_event signal for watch UUID {watch_uuid}\")\n                else:\n                    # Send signal without UUID for system notifications\n                    self.notification_event_signal.send()\n                    logger.trace(\"NotificationQueue: Emitted notification_event signal for system notification\")\n        except Exception as e:\n            logger.error(f\"Exception emitting notification_event signal: {e}\")\n\nclass SignalPriorityQueue(queue.PriorityQueue):\n    \"\"\"\n    Extended PriorityQueue that sends a signal when items with a UUID are added.\n    \n    This class extends the standard PriorityQueue and adds a signal emission\n    after an item is put into the queue. If the item contains a UUID, the signal\n    is sent with that UUID as a parameter.\n    \"\"\"\n    \n    def __init__(self, maxsize=0):\n        super().__init__(maxsize)\n        try:\n            self.queue_length_signal = signal('queue_length')\n        except Exception as e:\n            logger.critical(f\"Exception: {e}\")\n\n    def put(self, item, block=True, timeout=None):\n        # Call the parent's put method first\n        super().put(item, block, timeout)\n\n        # After putting the item in the queue, check if it has a UUID and emit signal\n        if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:\n            uuid = item.item['uuid']\n            # Get the signal and send it if it exists\n            watch_check_update = signal('watch_check_update')\n            if watch_check_update:\n                # NOTE: This would block other workers from .put/.get while this signal sends\n                # Signal handlers may iterate the queue/datastore while holding locks\n                watch_check_update.send(watch_uuid=uuid)\n        \n        # Send queue_length signal with current queue size\n        try:\n\n            if self.queue_length_signal:\n                self.queue_length_signal.send(length=self.qsize())\n        except Exception as e:\n            logger.critical(f\"Exception: {e}\")\n\n    def get(self, block=True, timeout=None):\n        # Call the parent's get method first\n        item = super().get(block, timeout)\n        \n        # Send queue_length signal with current queue size\n        try:\n            if self.queue_length_signal:\n                self.queue_length_signal.send(length=self.qsize())\n        except Exception as e:\n            logger.critical(f\"Exception: {e}\")\n        return item\n    \n    def get_uuid_position(self, target_uuid):\n        \"\"\"\n        Find the position of a watch UUID in the priority queue.\n        Optimized for large queues - O(n) complexity instead of O(n log n).\n        \n        Args:\n            target_uuid: The UUID to search for\n            \n        Returns:\n            dict: Contains position info or None if not found\n                - position: 0-based position in queue (0 = next to be processed)\n                - total_items: total number of items in queue\n                - priority: the priority value of the found item\n        \"\"\"\n        with self.mutex:\n            queue_list = list(self.queue)\n            total_items = len(queue_list)\n            \n            if total_items == 0:\n                return {\n                    'position': None,\n                    'total_items': 0,\n                    'priority': None,\n                    'found': False\n                }\n            \n            # Find the target item and its priority first - O(n)\n            target_item = None\n            target_priority = None\n            \n            for item in queue_list:\n                if (hasattr(item, 'item') and \n                    isinstance(item.item, dict) and \n                    item.item.get('uuid') == target_uuid):\n                    target_item = item\n                    target_priority = item.priority\n                    break\n            \n            if target_item is None:\n                return {\n                    'position': None,\n                    'total_items': total_items,\n                    'priority': None,\n                    'found': False\n                }\n            \n            # Count how many items have higher priority (lower numbers) - O(n)\n            position = 0\n            for item in queue_list:\n                # Items with lower priority numbers are processed first\n                if item.priority < target_priority:\n                    position += 1\n                elif item.priority == target_priority and item != target_item:\n                    # For same priority, count items that come before this one\n                    # (Note: this is approximate since heap order isn't guaranteed for equal priorities)\n                    position += 1\n            \n            return {\n                'position': position,\n                'total_items': total_items,\n                'priority': target_priority,\n                'found': True\n            }\n    \n    def get_all_queued_uuids(self, limit=None, offset=0):\n        \"\"\"\n        Get UUIDs currently in the queue with their positions.\n        For large queues, use limit/offset for pagination.\n        \n        Args:\n            limit: Maximum number of items to return (None = all)\n            offset: Number of items to skip (for pagination)\n        \n        Returns:\n            dict: Contains items and metadata\n                - items: List of dicts with uuid, position, and priority\n                - total_items: Total number of items in queue\n                - returned_items: Number of items returned\n                - has_more: Whether there are more items after this page\n        \"\"\"\n        with self.mutex:\n            queue_list = list(self.queue)\n            total_items = len(queue_list)\n            \n            if total_items == 0:\n                return {\n                    'items': [],\n                    'total_items': 0,\n                    'returned_items': 0,\n                    'has_more': False\n                }\n            \n            # For very large queues, warn about performance\n            if total_items > 1000 and limit is None:\n                logger.warning(f\"Getting all {total_items} queued items without limit - this may be slow\")\n            \n            # Sort only if we need exact positions (expensive for large queues)\n            if limit is not None and limit <= 100:\n                # For small requests, we can afford to sort\n                queue_items = sorted(queue_list)\n                end_idx = min(offset + limit, len(queue_items)) if limit else len(queue_items)\n                items_to_process = queue_items[offset:end_idx]\n                \n                result = []\n                for position, item in enumerate(items_to_process, start=offset):\n                    if (hasattr(item, 'item') and \n                        isinstance(item.item, dict) and \n                        'uuid' in item.item):\n                        \n                        result.append({\n                            'uuid': item.item['uuid'],\n                            'position': position,\n                            'priority': item.priority\n                        })\n                \n                return {\n                    'items': result,\n                    'total_items': total_items,\n                    'returned_items': len(result),\n                    'has_more': (offset + len(result)) < total_items\n                }\n            else:\n                # For large requests, return items with approximate positions\n                # This is much faster O(n) instead of O(n log n)\n                result = []\n                processed = 0\n                skipped = 0\n                \n                for item in queue_list:\n                    if (hasattr(item, 'item') and \n                        isinstance(item.item, dict) and \n                        'uuid' in item.item):\n                        \n                        if skipped < offset:\n                            skipped += 1\n                            continue\n                        \n                        if limit and processed >= limit:\n                            break\n                        \n                        # Approximate position based on priority comparison\n                        approx_position = sum(1 for other in queue_list if other.priority < item.priority)\n                        \n                        result.append({\n                            'uuid': item.item['uuid'],\n                            'position': approx_position,  # Approximate\n                            'priority': item.priority\n                        })\n                        processed += 1\n                \n                return {\n                    'items': result,\n                    'total_items': total_items,\n                    'returned_items': len(result),\n                    'has_more': (offset + len(result)) < total_items,\n                    'note': 'Positions are approximate for performance with large queues'\n                }\n    \n    def get_queue_summary(self):\n        \"\"\"\n        Get a quick summary of queue state without expensive operations.\n        O(n) complexity - fast even for large queues.\n        \n        Returns:\n            dict: Queue summary statistics\n        \"\"\"\n        with self.mutex:\n            queue_list = list(self.queue)\n            total_items = len(queue_list)\n            \n            if total_items == 0:\n                return {\n                    'total_items': 0,\n                    'priority_breakdown': {},\n                    'immediate_items': 0,\n                    'clone_items': 0,\n                    'scheduled_items': 0\n                }\n            \n            # Count items by priority type - O(n)\n            immediate_items = 0  # priority 1\n            clone_items = 0      # priority 5  \n            scheduled_items = 0  # priority > 100 (timestamps)\n            priority_counts = {}\n            \n            for item in queue_list:\n                priority = item.priority\n                priority_counts[priority] = priority_counts.get(priority, 0) + 1\n                \n                if priority == 1:\n                    immediate_items += 1\n                elif priority == 5:\n                    clone_items += 1\n                elif priority > 100:\n                    scheduled_items += 1\n            \n            return {\n                'total_items': total_items,\n                'priority_breakdown': priority_counts,\n                'immediate_items': immediate_items,\n                'clone_items': clone_items,\n                'scheduled_items': scheduled_items,\n                'min_priority': min(priority_counts.keys()) if priority_counts else None,\n                'max_priority': max(priority_counts.keys()) if priority_counts else None\n            }\n\n\nclass AsyncSignalPriorityQueue(asyncio.PriorityQueue):\n    \"\"\"\n    Async version of SignalPriorityQueue that sends signals when items are added/removed.\n    \n    This class extends asyncio.PriorityQueue and maintains the same signal behavior\n    as the synchronous version for real-time UI updates.\n    \"\"\"\n    \n    def __init__(self, maxsize=0):\n        super().__init__(maxsize)\n        try:\n            self.queue_length_signal = signal('queue_length')\n        except Exception as e:\n            logger.critical(f\"Exception: {e}\")\n\n    async def put(self, item):\n        # Call the parent's put method first\n        await super().put(item)\n\n        # After putting the item in the queue, check if it has a UUID and emit signal\n        if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:\n            uuid = item.item['uuid']\n            # Get the signal and send it if it exists\n            watch_check_update = signal('watch_check_update')\n            if watch_check_update:\n                # NOTE: This would block other workers from .put/.get while this signal sends\n                # Signal handlers may iterate the queue/datastore while holding locks\n                watch_check_update.send(watch_uuid=uuid)\n        \n        # Send queue_length signal with current queue size\n        try:\n            if self.queue_length_signal:\n                self.queue_length_signal.send(length=self.qsize())\n        except Exception as e:\n            logger.critical(f\"Exception: {e}\")\n\n    async def get(self):\n        # Call the parent's get method first\n        item = await super().get()\n        \n        # Send queue_length signal with current queue size\n        try:\n            if self.queue_length_signal:\n                self.queue_length_signal.send(length=self.qsize())\n        except Exception as e:\n            logger.critical(f\"Exception: {e}\")\n        return item\n    \n    @property\n    def queue(self):\n        \"\"\"\n        Provide compatibility with sync PriorityQueue.queue access\n        Returns the internal queue for template access\n        \"\"\"\n        return self._queue if hasattr(self, '_queue') else []\n    \n    def get_uuid_position(self, target_uuid):\n        \"\"\"\n        Find the position of a watch UUID in the async priority queue.\n        Optimized for large queues - O(n) complexity instead of O(n log n).\n        \n        Args:\n            target_uuid: The UUID to search for\n            \n        Returns:\n            dict: Contains position info or None if not found\n                - position: 0-based position in queue (0 = next to be processed)\n                - total_items: total number of items in queue\n                - priority: the priority value of the found item\n        \"\"\"\n        queue_list = list(self._queue)\n        total_items = len(queue_list)\n        \n        if total_items == 0:\n            return {\n                'position': None,\n                'total_items': 0,\n                'priority': None,\n                'found': False\n            }\n        \n        # Find the target item and its priority first - O(n)\n        target_item = None\n        target_priority = None\n        \n        for item in queue_list:\n            if (hasattr(item, 'item') and \n                isinstance(item.item, dict) and \n                item.item.get('uuid') == target_uuid):\n                target_item = item\n                target_priority = item.priority\n                break\n        \n        if target_item is None:\n            return {\n                'position': None,\n                'total_items': total_items,\n                'priority': None,\n                'found': False\n            }\n        \n        # Count how many items have higher priority (lower numbers) - O(n)\n        position = 0\n        for item in queue_list:\n            if item.priority < target_priority:\n                position += 1\n            elif item.priority == target_priority and item != target_item:\n                position += 1\n        \n        return {\n            'position': position,\n            'total_items': total_items,\n            'priority': target_priority,\n            'found': True\n        }\n    \n    def get_all_queued_uuids(self, limit=None, offset=0):\n        \"\"\"\n        Get UUIDs currently in the async queue with their positions.\n        For large queues, use limit/offset for pagination.\n        \n        Args:\n            limit: Maximum number of items to return (None = all)\n            offset: Number of items to skip (for pagination)\n        \n        Returns:\n            dict: Contains items and metadata (same structure as sync version)\n        \"\"\"\n        queue_list = list(self._queue)\n        total_items = len(queue_list)\n        \n        if total_items == 0:\n            return {\n                'items': [],\n                'total_items': 0,\n                'returned_items': 0,\n                'has_more': False\n            }\n        \n        # Same logic as sync version but without mutex\n        if limit is not None and limit <= 100:\n            queue_items = sorted(queue_list)\n            end_idx = min(offset + limit, len(queue_items)) if limit else len(queue_items)\n            items_to_process = queue_items[offset:end_idx]\n            \n            result = []\n            for position, item in enumerate(items_to_process, start=offset):\n                if (hasattr(item, 'item') and \n                    isinstance(item.item, dict) and \n                    'uuid' in item.item):\n                    \n                    result.append({\n                        'uuid': item.item['uuid'],\n                        'position': position,\n                        'priority': item.priority\n                    })\n            \n            return {\n                'items': result,\n                'total_items': total_items,\n                'returned_items': len(result),\n                'has_more': (offset + len(result)) < total_items\n            }\n        else:\n            # Fast approximate positions for large queues\n            result = []\n            processed = 0\n            skipped = 0\n            \n            for item in queue_list:\n                if (hasattr(item, 'item') and \n                    isinstance(item.item, dict) and \n                    'uuid' in item.item):\n                    \n                    if skipped < offset:\n                        skipped += 1\n                        continue\n                    \n                    if limit and processed >= limit:\n                        break\n                    \n                    approx_position = sum(1 for other in queue_list if other.priority < item.priority)\n                    \n                    result.append({\n                        'uuid': item.item['uuid'],\n                        'position': approx_position,\n                        'priority': item.priority\n                    })\n                    processed += 1\n            \n            return {\n                'items': result,\n                'total_items': total_items,\n                'returned_items': len(result),\n                'has_more': (offset + len(result)) < total_items,\n                'note': 'Positions are approximate for performance with large queues'\n            }\n    \n    def get_queue_summary(self):\n        \"\"\"\n        Get a quick summary of async queue state.\n        O(n) complexity - fast even for large queues.\n        \"\"\"\n        queue_list = list(self._queue)\n        total_items = len(queue_list)\n        \n        if total_items == 0:\n            return {\n                'total_items': 0,\n                'priority_breakdown': {},\n                'immediate_items': 0,\n                'clone_items': 0,\n                'scheduled_items': 0\n            }\n        \n        immediate_items = 0\n        clone_items = 0\n        scheduled_items = 0\n        priority_counts = {}\n        \n        for item in queue_list:\n            priority = item.priority\n            priority_counts[priority] = priority_counts.get(priority, 0) + 1\n            \n            if priority == 1:\n                immediate_items += 1\n            elif priority == 5:\n                clone_items += 1\n            elif priority > 100:\n                scheduled_items += 1\n        \n        return {\n            'total_items': total_items,\n            'priority_breakdown': priority_counts,\n            'immediate_items': immediate_items,\n            'clone_items': clone_items,\n            'scheduled_items': scheduled_items,\n            'min_priority': min(priority_counts.keys()) if priority_counts else None,\n            'max_priority': max(priority_counts.keys()) if priority_counts else None\n        }\n"
  },
  {
    "path": "changedetectionio/diff/__init__.py",
    "content": "\"\"\"\nDiff rendering module for change detection.\n\nThis module provides functions for rendering differences between text content,\nwith support for various output formats and tokenization strategies.\n\"\"\"\n\nimport difflib\nfrom typing import List, Iterator, Union\nfrom loguru import logger\nimport diff_match_patch as dmp_module\nimport re\nimport time\n\nfrom .tokenizers import TOKENIZERS, tokenize_words_and_html\n\n# Remember! gmail, outlook etc dont support <style> must be inline.\n# Gmail: strips <ins> and <del> tags entirely.\n# This is for the WHOLE line background style\nREMOVED_STYLE = \"background-color: #fadad7; color: #b30000;\"\nADDED_STYLE = \"background-color: #eaf2c2; color: #406619;\"\nHTML_REMOVED_STYLE = REMOVED_STYLE  # Export alias for handler.py\nHTML_ADDED_STYLE = ADDED_STYLE      # Export alias for handler.py\n\n# Darker backgrounds for nested highlighting (changed parts within lines)\nREMOVED_INNER_STYLE = \"background-color: #ff867a; color: #111;\"\nADDED_INNER_STYLE = \"background-color: #b2e841; color: #444;\"\nHTML_CHANGED_STYLE = REMOVED_STYLE\nHTML_CHANGED_INTO_STYLE = ADDED_STYLE\n\n# Placemarker constants - these get replaced by apply_service_tweaks() in handler.py\n# Something that cant get escaped to HTML by accident\nREMOVED_PLACEMARKER_OPEN = '@removed_PLACEMARKER_OPEN'\nREMOVED_PLACEMARKER_CLOSED = '@removed_PLACEMARKER_CLOSED'\n\nADDED_PLACEMARKER_OPEN = '@added_PLACEMARKER_OPEN'\nADDED_PLACEMARKER_CLOSED = '@added_PLACEMARKER_CLOSED'\n\nCHANGED_PLACEMARKER_OPEN = '@changed_PLACEMARKER_OPEN'\nCHANGED_PLACEMARKER_CLOSED = '@changed_PLACEMARKER_CLOSED'\n\nCHANGED_INTO_PLACEMARKER_OPEN = '@changed_into_PLACEMARKER_OPEN'\nCHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'\n\n# Compiled regex patterns for performance\nWHITESPACE_NORMALIZE_RE = re.compile(r'\\s+')\n\n\ndef render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]:\n    \"\"\"\n    Render word-level differences between two lines inline using diff-match-patch library.\n\n    Args:\n        before_line: Original line text\n        after_line: Modified line text\n        ignore_junk: Ignore whitespace-only changes\n        markdown_style: Unused (kept for backwards compatibility)\n        tokenizer: Name of tokenizer to use from TOKENIZERS registry (default: 'words_and_html')\n\n    Returns:\n        tuple[str, bool]: (diff output with inline word-level highlighting, has_changes flag)\n    \"\"\"\n    # Normalize whitespace if ignore_junk is enabled\n    if ignore_junk:\n        # Normalize whitespace: replace multiple spaces/tabs with single space\n        before_normalized = WHITESPACE_NORMALIZE_RE.sub(' ', before_line)\n        after_normalized = WHITESPACE_NORMALIZE_RE.sub(' ', after_line)\n    else:\n        before_normalized = before_line\n        after_normalized = after_line\n\n    # Use diff-match-patch with word-level tokenization\n    # Strategy: Use linesToChars to treat words as atomic units\n    dmp = dmp_module.diff_match_patch()\n\n    # Get the tokenizer function from the registry\n    tokenizer_func = TOKENIZERS.get(tokenizer, tokenize_words_and_html)\n\n    # Tokenize both lines using the selected tokenizer\n    before_tokens = tokenizer_func(before_normalized)\n    after_tokens = tokenizer_func(after_normalized or ' ')\n\n    # Create mappings for linesToChars (using it for word-mode)\n    # Join tokens with newline so each \"line\" is a token\n    before_text = '\\n'.join(before_tokens)\n    after_text = '\\n'.join(after_tokens)\n\n    # Use linesToChars for word-mode diffing\n    lines_result = dmp.diff_linesToChars(before_text, after_text)\n    line_before, line_after, line_array = lines_result\n\n    # Perform diff on the encoded strings\n    diffs = dmp.diff_main(line_before, line_after, False)\n\n    # Convert back to original text\n    dmp.diff_charsToLines(diffs, line_array)\n\n    # Remove the newlines we added for tokenization\n    diffs = [(op, text.replace('\\n', '')) for op, text in diffs]\n\n    # DON'T apply semantic cleanup here - it would break token boundaries\n    # (e.g., \"63\" -> \"66\" would become \"6\" + \"3\" vs \"6\" + \"6\")\n    # We want to preserve the tokenizer's word boundaries\n\n    # Check if there are any changes\n    has_changes = any(op != 0 for op, _ in diffs)\n\n    if ignore_junk and not has_changes:\n        return after_line, False\n\n    # Check if the whole line is replaced (no unchanged content)\n    whole_line_replaced = not any(op == 0 and text.strip() for op, text in diffs)\n\n    # Build the output using placemarkers\n    # When whole line is replaced, wrap entire removed content once and entire added content once\n    if whole_line_replaced:\n        removed_tokens = []\n        added_tokens = []\n\n        for op, text in diffs:\n            if op == 0:  # Equal (e.g., whitespace tokens in common positions)\n                # Include in both removed and added to preserve spacing\n                removed_tokens.append(text)\n                added_tokens.append(text)\n            elif op == -1:  # Deletion\n                removed_tokens.append(text)\n            elif op == 1:  # Insertion\n                added_tokens.append(text)\n\n        # Join all tokens and wrap the entire string once for removed, once for added\n        result_parts = []\n\n        if removed_tokens:\n            removed_full = ''.join(removed_tokens).rstrip()\n            trailing_removed = ''.join(removed_tokens)[len(removed_full):] if len(''.join(removed_tokens)) > len(removed_full) else ''\n            result_parts.append(f'{CHANGED_PLACEMARKER_OPEN}{removed_full}{CHANGED_PLACEMARKER_CLOSED}{trailing_removed}')\n\n        if added_tokens:\n            if result_parts:  # Add newline between removed and added\n                result_parts.append('\\n')\n            added_full = ''.join(added_tokens).rstrip()\n            trailing_added = ''.join(added_tokens)[len(added_full):] if len(''.join(added_tokens)) > len(added_full) else ''\n            result_parts.append(f'{CHANGED_INTO_PLACEMARKER_OPEN}{added_full}{CHANGED_INTO_PLACEMARKER_CLOSED}{trailing_added}')\n\n        return ''.join(result_parts), has_changes\n    else:\n        # Inline changes within the line\n        result_parts = []\n        for op, text in diffs:\n            if op == 0:  # Equal\n                result_parts.append(text)\n            elif op == 1:  # Insertion\n                # Don't wrap empty content (e.g., whitespace-only tokens after rstrip)\n                content = text.rstrip()\n                trailing = text[len(content):] if len(text) > len(content) else ''\n                if content:\n                    result_parts.append(f'{ADDED_PLACEMARKER_OPEN}{content}{ADDED_PLACEMARKER_CLOSED}{trailing}')\n                else:\n                    result_parts.append(trailing)\n            elif op == -1:  # Deletion\n                # Don't wrap empty content (e.g., whitespace-only tokens after rstrip)\n                content = text.rstrip()\n                trailing = text[len(content):] if len(text) > len(content) else ''\n                if content:\n                    result_parts.append(f'{REMOVED_PLACEMARKER_OPEN}{content}{REMOVED_PLACEMARKER_CLOSED}{trailing}')\n                else:\n                    result_parts.append(trailing)\n\n        return ''.join(result_parts), has_changes\n\n\ndef render_nested_line_diff(before_line: str, after_line: str, ignore_junk: bool = False, tokenizer: str = 'words_and_html') -> tuple[str, str, bool]:\n    \"\"\"\n    Render line-level differences with nested highlighting for changed parts.\n\n    Returns two separate lines:\n    - Before line: light red background with dark red on removed parts\n    - After line: light green background with dark green on added parts\n\n    Args:\n        before_line: Original line text\n        after_line: Modified line text\n        ignore_junk: Ignore whitespace-only changes\n        tokenizer: Name of tokenizer to use from TOKENIZERS registry\n\n    Returns:\n        tuple[str, str, bool]: (before_with_highlights, after_with_highlights, has_changes)\n    \"\"\"\n    # Normalize whitespace if ignore_junk is enabled\n    if ignore_junk:\n        before_normalized = WHITESPACE_NORMALIZE_RE.sub(' ', before_line)\n        after_normalized = WHITESPACE_NORMALIZE_RE.sub(' ', after_line)\n    else:\n        before_normalized = before_line\n        after_normalized = after_line\n\n    # Use diff-match-patch with word-level tokenization\n    dmp = dmp_module.diff_match_patch()\n\n    # Get the tokenizer function from the registry\n    tokenizer_func = TOKENIZERS.get(tokenizer, tokenize_words_and_html)\n\n    # Tokenize both lines\n    before_tokens = tokenizer_func(before_normalized)\n    after_tokens = tokenizer_func(after_normalized or ' ')\n\n    # Create mappings for linesToChars\n    before_text = '\\n'.join(before_tokens)\n    after_text = '\\n'.join(after_tokens)\n\n    # Use linesToChars for word-mode diffing\n    lines_result = dmp.diff_linesToChars(before_text, after_text)\n    line_before, line_after, line_array = lines_result\n\n    # Perform diff on the encoded strings\n    diffs = dmp.diff_main(line_before, line_after, False)\n\n    # Convert back to original text\n    dmp.diff_charsToLines(diffs, line_array)\n\n    # Remove the newlines we added for tokenization\n    diffs = [(op, text.replace('\\n', '')) for op, text in diffs]\n\n    # DON'T apply semantic cleanup here - it would break token boundaries\n    # (e.g., \"63\" -> \"66\" would become \"6\" + \"3\" vs \"6\" + \"6\")\n    # We want to preserve the tokenizer's word boundaries\n\n    # Check if there are any changes\n    has_changes = any(op != 0 for op, _ in diffs)\n\n    if ignore_junk and not has_changes:\n        return before_line, after_line, False\n\n    # Build the before line (with nested highlighting for removed parts)\n    before_parts = []\n    for op, text in diffs:\n        if op == 0:  # Equal\n            before_parts.append(text)\n        elif op == -1:  # Deletion (in before)\n            before_parts.append(f'<span style=\"{REMOVED_INNER_STYLE}\">{text}</span>')\n        # Skip insertions (op == 1) for the before line\n\n    before_content = ''.join(before_parts)\n\n    # Build the after line (with nested highlighting for added parts)\n    after_parts = []\n    for op, text in diffs:\n        if op == 0:  # Equal\n            after_parts.append(text)\n        elif op == 1:  # Insertion (in after)\n            after_parts.append(f'<span style=\"{ADDED_INNER_STYLE}\">{text}</span>')\n        # Skip deletions (op == -1) for the after line\n\n    after_content = ''.join(after_parts)\n\n    # Wrap content with placemarkers (inner HTML highlighting is preserved)\n    before_html = f'{CHANGED_PLACEMARKER_OPEN}{before_content}{CHANGED_PLACEMARKER_CLOSED}'\n    after_html = f'{CHANGED_INTO_PLACEMARKER_OPEN}{after_content}{CHANGED_INTO_PLACEMARKER_CLOSED}'\n\n    return before_html, after_html, has_changes\n\n\ndef same_slicer(lst: List[str], start: int, end: int) -> List[str]:\n    \"\"\"Return a slice of the list, or a single element if start == end.\"\"\"\n    return lst[start:end] if start != end else [lst[start]]\n\ndef customSequenceMatcher(\n    before: List[str],\n    after: List[str],\n    include_equal: bool = False,\n    include_removed: bool = True,\n    include_added: bool = True,\n    include_replaced: bool = True,\n    include_change_type_prefix: bool = True,\n    word_diff: bool = False,\n    context_lines: int = 0,\n    case_insensitive: bool = False,\n    ignore_junk: bool = False,\n    tokenizer: str = 'words_and_html'\n) -> Iterator[List[str]]:\n    \"\"\"\n    Compare two sequences and yield differences based on specified parameters.\n\n    Args:\n        before (List[str]): Original sequence\n        after (List[str]): Modified sequence\n        include_equal (bool): Include unchanged parts\n        include_removed (bool): Include removed parts\n        include_added (bool): Include added parts\n        include_replaced (bool): Include replaced parts\n        include_change_type_prefix (bool): Add prefixes to indicate change types\n        word_diff (bool): Use word-level diffing for replaced lines (controls inline rendering)\n        context_lines (int): Number of unchanged lines to show around changes (like grep -C)\n        case_insensitive (bool): Perform case-insensitive comparison\n        ignore_junk (bool): Ignore whitespace-only changes\n        tokenizer (str): Name of tokenizer to use from TOKENIZERS registry (default: 'words_and_html')\n\n    Yields:\n        List[str]: Differences between sequences\n    \"\"\"\n    # Prepare sequences for comparison (lowercase if case-insensitive, normalize whitespace if ignore_junk)\n    def prepare_line(line):\n        if case_insensitive:\n            line = line.lower()\n        if ignore_junk:\n            # Normalize whitespace: replace multiple spaces/tabs with single space\n            line = WHITESPACE_NORMALIZE_RE.sub(' ', line)\n        return line\n\n    compare_before = [prepare_line(line) for line in before]\n    compare_after = [prepare_line(line) for line in after]\n\n    cruncher = difflib.SequenceMatcher(isjunk=lambda x: x in \" \\t\", a=compare_before, b=compare_after)\n\n    # When context_lines is set and include_equal is False, we need to track which equal lines to include\n    if context_lines > 0 and not include_equal:\n        opcodes = list(cruncher.get_opcodes())\n        # Mark equal ranges that should be included based on context\n        included_equal_ranges = set()\n\n        for i, (tag, alo, ahi, blo, bhi) in enumerate(opcodes):\n            if tag != 'equal':\n                # Include context lines before this change\n                for j in range(max(0, i - 1), i):\n                    if opcodes[j][0] == 'equal':\n                        prev_alo, prev_ahi = opcodes[j][1], opcodes[j][2]\n                        # Include last N lines of the previous equal block\n                        context_start = max(prev_alo, prev_ahi - context_lines)\n                        for line_num in range(context_start, prev_ahi):\n                            included_equal_ranges.add(line_num)\n\n                # Include context lines after this change\n                for j in range(i + 1, min(len(opcodes), i + 2)):\n                    if opcodes[j][0] == 'equal':\n                        next_alo, next_ahi = opcodes[j][1], opcodes[j][2]\n                        # Include first N lines of the next equal block\n                        context_end = min(next_ahi, next_alo + context_lines)\n                        for line_num in range(next_alo, context_end):\n                            included_equal_ranges.add(line_num)\n\n    # Remember! gmail, outlook etc dont support <style> must be inline.\n    # Gmail: strips <ins> and <del> tags entirely.\n    for tag, alo, ahi, blo, bhi in cruncher.get_opcodes():\n        if tag == 'equal':\n            if include_equal:\n                yield before[alo:ahi]\n            elif context_lines > 0:\n                # Only include equal lines that are in the context range\n                context_lines_to_include = [before[i] for i in range(alo, ahi) if i in included_equal_ranges]\n                if context_lines_to_include:\n                    yield context_lines_to_include\n        elif include_removed and tag == 'delete':\n            if include_change_type_prefix:\n                yield [f'{REMOVED_PLACEMARKER_OPEN}{line}{REMOVED_PLACEMARKER_CLOSED}' for line in same_slicer(before, alo, ahi)]\n            else:\n                yield same_slicer(before, alo, ahi)\n        elif include_replaced and tag == 'replace':\n            before_lines = same_slicer(before, alo, ahi)\n            after_lines = same_slicer(after, blo, bhi)\n\n            # Use inline word-level diff for single line replacements when word_diff is enabled\n            if word_diff and len(before_lines) == 1 and len(after_lines) == 1:\n                inline_diff, has_changes = render_inline_word_diff(before_lines[0], after_lines[0], ignore_junk=ignore_junk, tokenizer=tokenizer)\n                # Check if there are any actual changes (not just whitespace when ignore_junk is enabled)\n                if ignore_junk and not has_changes:\n                    # No real changes, skip this line\n                    continue\n                yield [inline_diff]\n            else:\n                # Fall back to line-level diff for multi-line changes\n                if include_change_type_prefix:\n                    yield [f'{CHANGED_PLACEMARKER_OPEN}{line}{CHANGED_PLACEMARKER_CLOSED}' for line in before_lines] + \\\n                          [f'{CHANGED_INTO_PLACEMARKER_OPEN}{line}{CHANGED_INTO_PLACEMARKER_CLOSED}' for line in after_lines]\n                else:\n                    yield before_lines + after_lines\n        elif include_added and tag == 'insert':\n            if include_change_type_prefix:\n                yield [f'{ADDED_PLACEMARKER_OPEN}{line}{ADDED_PLACEMARKER_CLOSED}' for line in same_slicer(after, blo, bhi)]\n            else:\n                yield same_slicer(after, blo, bhi)\n\ndef render_diff(\n    previous_version_file_contents: str,\n    newest_version_file_contents: str,\n    include_equal: bool = False,\n    include_removed: bool = True,\n    include_added: bool = True,\n    include_replaced: bool = True,\n    include_change_type_prefix: bool = True,\n    patch_format: bool = False,\n    word_diff: bool = True,\n    context_lines: int = 0,\n    case_insensitive: bool = False,\n    ignore_junk: bool = False,\n    tokenizer: str = 'words_and_html'\n) -> str:\n    \"\"\"\n    Render the difference between two file contents.\n\n    Args:\n        previous_version_file_contents (str): Original file contents\n        newest_version_file_contents (str): Modified file contents\n        include_equal (bool): Include unchanged parts\n        include_removed (bool): Include removed parts\n        include_added (bool): Include added parts\n        include_replaced (bool): Include replaced parts\n        include_change_type_prefix (bool): Add prefixes to indicate change types\n        patch_format (bool): Use patch format for output\n        word_diff (bool): Use word-level diffing for replaced lines (controls inline rendering)\n        context_lines (int): Number of unchanged lines to show around changes (like grep -C)\n        case_insensitive (bool): Perform case-insensitive comparison, By default the test_json_diff/process.py is case sensitive, so this follows same logic\n        ignore_junk (bool): Ignore whitespace-only changes\n        tokenizer (str): Name of tokenizer to use from TOKENIZERS registry (default: 'words_and_html')\n\n    Returns:\n        str: Rendered difference\n    \"\"\"\n    newest_lines = [line.rstrip() for line in newest_version_file_contents.splitlines()]\n    previous_lines = [line.rstrip() for line in previous_version_file_contents.splitlines()] if previous_version_file_contents else []\n    now = time.time()\n    logger.debug(\n        f\"diff options: \"\n        f\"include_equal={include_equal}, \"\n        f\"include_removed={include_removed}, \"\n        f\"include_added={include_added}, \"\n        f\"include_replaced={include_replaced}, \"\n        f\"include_change_type_prefix={include_change_type_prefix}, \"\n        f\"patch_format={patch_format}, \"\n        f\"word_diff={word_diff}, \"\n        f\"context_lines={context_lines}, \"\n        f\"case_insensitive={case_insensitive}, \"\n        f\"ignore_junk={ignore_junk}, \"\n        f\"tokenizer={tokenizer}\"\n    )\n    if patch_format:\n        patch = difflib.unified_diff(previous_lines, newest_lines)\n        return \"\\n\".join(patch)\n\n    rendered_diff = customSequenceMatcher(\n        before=previous_lines,\n        after=newest_lines,\n        include_equal=include_equal,\n        include_removed=include_removed,\n        include_added=include_added,\n        include_replaced=include_replaced,\n        include_change_type_prefix=include_change_type_prefix,\n        word_diff=word_diff,\n        context_lines=context_lines,\n        case_insensitive=case_insensitive,\n        ignore_junk=ignore_junk,\n        tokenizer=tokenizer\n    )\n\n    def flatten(lst: List[Union[str, List[str]]]) -> str:\n        result = []\n        for x in lst:\n            if isinstance(x, list):\n                result.extend(x)\n            else:\n                result.append(x)\n        return \"\\n\".join(result)\n\n    logger.debug(f\"Diff generated in {time.time() - now:.2f}s\")\n\n    return flatten(rendered_diff)\n\n\n# Export main public API\n__all__ = [\n    'render_diff',\n    'customSequenceMatcher',\n    'render_inline_word_diff',\n    'render_nested_line_diff',\n    'TOKENIZERS',\n    'REMOVED_STYLE',\n    'ADDED_STYLE',\n    'REMOVED_INNER_STYLE',\n    'ADDED_INNER_STYLE',\n]\n"
  },
  {
    "path": "changedetectionio/diff/tokenizers/__init__.py",
    "content": "\"\"\"\nTokenizers for diff operations.\n\nThis module provides various tokenization strategies for use with the diff system.\nNew tokenizers can be easily added by:\n1. Creating a new module in this directory\n2. Importing and registering it in the TOKENIZERS dictionary below\n\"\"\"\n\nfrom .natural_text import tokenize_words\nfrom .words_and_html import tokenize_words_and_html\n\n# Tokenizer registry - maps tokenizer names to functions\nTOKENIZERS = {\n    'words': tokenize_words,\n    'words_and_html': tokenize_words_and_html,\n}\n\n__all__ = [\n    'tokenize_words',\n    'tokenize_words_and_html',\n    'TOKENIZERS',\n]\n"
  },
  {
    "path": "changedetectionio/diff/tokenizers/natural_text.py",
    "content": "\"\"\"\nSimple word tokenizer using whitespace boundaries.\n\nThis is a simpler tokenizer that treats all whitespace as token boundaries\nwithout special handling for HTML tags or other markup.\n\"\"\"\n\nfrom typing import List\n\n\ndef tokenize_words(text: str) -> List[str]:\n    \"\"\"\n    Split text into words using simple whitespace boundaries.\n\n    This is a simpler tokenizer that treats all whitespace as token boundaries\n    without special handling for HTML tags.\n\n    Args:\n        text: Input text to tokenize\n\n    Returns:\n        List of tokens (words and whitespace)\n\n    Examples:\n        >>> tokenize_words(\"Hello world\")\n        ['Hello', ' ', 'world']\n        >>> tokenize_words(\"one  two\")\n        ['one', ' ', ' ', 'two']\n    \"\"\"\n    tokens = []\n    current = ''\n\n    for char in text:\n        if char.isspace():\n            if current:\n                tokens.append(current)\n                current = ''\n            tokens.append(char)\n        else:\n            current += char\n\n    if current:\n        tokens.append(current)\n    return tokens\n"
  },
  {
    "path": "changedetectionio/diff/tokenizers/words_and_html.py",
    "content": "\"\"\"\nTokenizer that preserves HTML tags as atomic units while splitting on whitespace.\n\nThis tokenizer is specifically designed for HTML content where:\n- HTML tags should remain intact (e.g., '<p>', '<a href=\"...\">')\n- Whitespace tokens are preserved for accurate diff reconstruction\n- Words are split on whitespace boundaries\n\"\"\"\n\nfrom typing import List\n\n\ndef tokenize_words_and_html(text: str) -> List[str]:\n    \"\"\"\n    Split text into words and boundaries (spaces, HTML tags).\n\n    This tokenizer preserves HTML tags as atomic units while splitting on whitespace.\n    Useful for content that contains HTML markup.\n\n    Args:\n        text: Input text to tokenize\n\n    Returns:\n        List of tokens (words, spaces, HTML tags)\n\n    Examples:\n        >>> tokenize_words_and_html(\"<p>Hello world</p>\")\n        ['<p>', 'Hello', ' ', 'world', '</p>']\n        >>> tokenize_words_and_html(\"<a href='test.com'>link</a>\")\n        ['<a href=\\\\'test.com\\\\'>', 'link', '</a>']\n    \"\"\"\n    tokens = []\n    current = ''\n    in_tag = False\n\n    for char in text:\n        if char == '<':\n            # Start of HTML tag\n            if current:\n                tokens.append(current)\n                current = ''\n            current = '<'\n            in_tag = True\n        elif char == '>' and in_tag:\n            # End of HTML tag\n            current += '>'\n            tokens.append(current)\n            current = ''\n            in_tag = False\n        elif char.isspace() and not in_tag:\n            # Space outside of tag\n            if current:\n                tokens.append(current)\n                current = ''\n            tokens.append(char)\n        else:\n            current += char\n\n    if current:\n        tokens.append(current)\n    return tokens\n"
  },
  {
    "path": "changedetectionio/favicon_utils.py",
    "content": "\"\"\"\nFavicon utilities for changedetection.io\nHandles favicon MIME type detection with caching\n\"\"\"\n\nfrom functools import lru_cache\n\n\n@lru_cache(maxsize=1000)\ndef get_favicon_mime_type(filepath):\n    \"\"\"\n    Detect MIME type of favicon by reading file content using puremagic.\n    Results are cached to avoid repeatedly reading the same files.\n\n    Args:\n        filepath: Full path to the favicon file\n\n    Returns:\n        MIME type string (e.g., 'image/png')\n    \"\"\"\n    mime = None\n\n    try:\n        import puremagic\n        with open(filepath, 'rb') as f:\n            content_bytes = f.read(200)  # Read first 200 bytes\n\n        detections = puremagic.magic_string(content_bytes)\n        if detections:\n            mime = detections[0].mime_type\n    except Exception:\n        pass\n\n    # Fallback to mimetypes if puremagic fails\n    if not mime:\n        import mimetypes\n        mime, _ = mimetypes.guess_type(filepath)\n\n    # Final fallback based on extension\n    if not mime:\n        mime = 'image/x-icon' if filepath.endswith('.ico') else 'image/png'\n\n    return mime\n"
  },
  {
    "path": "changedetectionio/flask_app.py",
    "content": "#!/usr/bin/env python3\n\nimport flask_login\nimport locale\nimport os\nimport queue\nimport re\nimport sys\nimport threading\nimport time\nimport timeago\nfrom blinker import signal\nfrom pathlib import Path\n\nfrom changedetectionio.strtobool import strtobool\nfrom threading import Event\nfrom changedetectionio.queue_handlers import RecheckPriorityQueue, NotificationQueue\nfrom changedetectionio import worker_pool\n\nfrom flask import (\n    Flask,\n    abort,\n    flash,\n    redirect,\n    render_template,\n    request,\n    send_from_directory,\n    session,\n    url_for,\n)\nfrom flask_restful import abort, Api\nfrom flask_cors import CORS\n\n# Create specific signals for application events\n# Make this a global singleton to avoid multiple signal objects\nwatch_check_update = signal('watch_check_update', doc='Signal sent when a watch check is completed')\nfrom flask_wtf import CSRFProtect\nfrom flask_babel import Babel, gettext, get_locale\nfrom loguru import logger\n\nfrom changedetectionio import __version__\nfrom changedetectionio import queuedWatchMetaData\nfrom changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon, Spec\nfrom changedetectionio.api.Search import Search\nfrom .time_handler import is_within_schedule\nfrom changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale\nfrom changedetectionio.favicon_utils import get_favicon_mime_type\n\nIN_PYTEST = \"pytest\" in sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ\n\ndatastore = None\n\n# Local\nticker_thread = None\nextra_stylesheets = []\n\n# Use bulletproof janus-based queues for sync/async reliability  \nupdate_q = RecheckPriorityQueue()\nnotification_q = NotificationQueue()\nMAX_QUEUE_SIZE = 5000\n\napp = Flask(__name__,\n            static_url_path=\"\",\n            static_folder=\"static\",\n            template_folder=\"templates\")\n\n# Will be initialized in changedetection_app\nsocketio_server = None\n\n# Enable CORS, especially useful for the Chrome extension to operate from anywhere\nCORS(app)\nfrom werkzeug.routing import BaseConverter, ValidationError\nfrom uuid import UUID\n\nclass StrictUUIDConverter(BaseConverter):\n    # Special sentinel values allowed in addition to strict UUIDs\n    _ALLOWED_SENTINELS = frozenset({'first'})\n\n    def to_python(self, value: str) -> str:\n        if value in self._ALLOWED_SENTINELS:\n            return value\n        try:\n            u = UUID(value)\n        except ValueError as e:\n            raise ValidationError() from e\n        # Reject non-standard formats (braces, URNs, no-hyphens)\n        if str(u) != value.lower():\n            raise ValidationError()\n        return str(u)\n\n    def to_url(self, value) -> str:\n        return str(value)\n\n# app setup (once)\napp.url_map.converters[\"uuid_str\"] = StrictUUIDConverter\n\n# Flask-Compress handles HTTP compression, Socket.IO compression disabled to prevent memory leak.\n# There's also a bug between flask compress and socketio that causes some kind of slow memory leak\n# It's better to use compression on your reverse proxy (nginx etc) instead.\nif strtobool(os.getenv(\"FLASK_ENABLE_COMPRESSION\")):\n    from flask_compress import Compress as FlaskCompress\n    app.config['COMPRESS_MIN_SIZE'] = 2096\n    app.config['COMPRESS_MIMETYPES'] = ['text/html', 'text/css', 'text/javascript', 'application/json', 'application/javascript', 'image/svg+xml']\n    # Use gzip only - smaller memory footprint than zstd/brotli (4-8KB vs 200-500KB contexts)\n    app.config['COMPRESS_ALGORITHM'] = ['gzip']\n    compress = FlaskCompress()\n    compress.init_app(app)\n\napp.config['TEMPLATES_AUTO_RELOAD'] = False\n\n\n# Stop browser caching of assets\napp.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0\napp.config.exit = Event()\n\napp.config['NEW_VERSION_AVAILABLE'] = False\n\nif os.getenv('FLASK_SERVER_NAME'):\n    app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME')\n\n# Babel/i18n configuration\napp.config['BABEL_TRANSLATION_DIRECTORIES'] = str(Path(__file__).parent / 'translations')\napp.config['BABEL_DEFAULT_LOCALE'] = 'en_GB'\n\n# Session configuration\n# NOTE: Flask session (for locale, etc.) is separate from Flask-Login's remember-me cookie\n# - Flask session stores data like session['locale'] in a signed cookie\n# - Flask-Login's remember=True creates a separate authentication cookie\n# - Setting PERMANENT_SESSION_LIFETIME controls how long the Flask session cookie lasts\nfrom datetime import timedelta\napp.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=3650)  # ~10 years (effectively unlimited)\n\n#app.config[\"EXPLAIN_TEMPLATE_LOADING\"] = True\n\n\napp.jinja_env.add_extension('jinja2.ext.loopcontrols')\n\n# Configure Jinja2 to search for templates in plugin directories\ndef _configure_plugin_templates():\n    \"\"\"Configure Jinja2 loader to include plugin template directories.\"\"\"\n    from jinja2 import ChoiceLoader, FileSystemLoader\n    from changedetectionio.pluggy_interface import get_plugin_template_paths\n\n    # Get plugin template paths\n    plugin_template_paths = get_plugin_template_paths()\n\n    if plugin_template_paths:\n        # Create a ChoiceLoader that searches app templates first, then plugin templates\n        loaders = [app.jinja_loader]  # Keep the default app loader first\n        for path in plugin_template_paths:\n            loaders.append(FileSystemLoader(path))\n\n        app.jinja_loader = ChoiceLoader(loaders)\n        logger.info(f\"Configured Jinja2 to search {len(plugin_template_paths)} plugin template directories\")\n\n# Configure plugin templates (called after plugins are loaded)\n_configure_plugin_templates()\ncsrf = CSRFProtect()\ncsrf.init_app(app)\nnotification_debug_log=[]\n\n# Locale for correct presentation of prices etc\ndefault_locale = locale.getdefaultlocale()\nlogger.info(f\"System locale default is {default_locale}\")\ntry:\n    locale.setlocale(locale.LC_ALL, default_locale)\nexcept locale.Error:\n    logger.warning(f\"Unable to set locale {default_locale}, locale is not installed maybe?\")\n\nwatch_api = Api(app, decorators=[csrf.exempt])\n\ndef init_app_secret(datastore_path):\n    secret = \"\"\n\n    path = os.path.join(datastore_path, \"secret.txt\")\n\n    try:\n        with open(path, \"r\", encoding='utf-8') as f:\n            secret = f.read()\n\n    except FileNotFoundError:\n        import secrets\n        with open(path, \"w\", encoding='utf-8') as f:\n            secret = secrets.token_hex(32)\n            f.write(secret)\n\n    return secret\n\n\n@app.template_global()\ndef get_darkmode_state():\n    css_dark_mode = request.cookies.get('css_dark_mode', 'false')\n    return 'true' if css_dark_mode and strtobool(css_dark_mode) else 'false'\n\n@app.template_global()\ndef get_css_version():\n    return __version__\n\n@app.template_global()\ndef get_socketio_path():\n    \"\"\"Generate the correct Socket.IO path prefix for the client\"\"\"\n    # If behind a proxy with a sub-path, we need to respect that path\n    prefix = \"\"\n    if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:\n        prefix = request.headers['X-Forwarded-Prefix']\n\n    # Socket.IO will be available at {prefix}/socket.io/\n    return prefix\n\n@app.template_global('is_safe_valid_url')\ndef _is_safe_valid_url(test_url):\n    from .validate_url import is_safe_valid_url\n    return is_safe_valid_url(test_url)\n\n\n@app.template_filter('format_number_locale')\ndef _jinja2_filter_format_number_locale(value: float) -> str:\n    \"Formats for example 4000.10 to the local locale default of 4,000.10\"\n    # Format the number with two decimal places (locale format string will return 6 decimal)\n    formatted_value = locale.format_string(\"%.2f\", value, grouping=True)\n    return formatted_value\n\n@app.template_filter('regex_search')\ndef _jinja2_filter_regex_search(value, pattern):\n    import re\n    return re.search(pattern, str(value)) is not None\n\n@app.template_global('is_checking_now')\ndef _watch_is_checking_now(watch_obj, format=\"%Y-%m-%d %H:%M:%S\"):\n    return worker_pool.is_watch_running(watch_obj['uuid'])\n\n@app.template_global('get_watch_queue_position')\ndef _get_watch_queue_position(watch_obj):\n    \"\"\"Get the position of a watch in the queue\"\"\"\n    uuid = watch_obj['uuid']\n    return update_q.get_uuid_position(uuid)\n\n@app.template_global('get_current_worker_count')\ndef _get_current_worker_count():\n    \"\"\"Get the current number of operational workers\"\"\"\n    return worker_pool.get_worker_count()\n\n@app.template_global('get_worker_status_info')\ndef _get_worker_status_info():\n    \"\"\"Get detailed worker status information for display\"\"\"\n    status = worker_pool.get_worker_status()\n    running_uuids = worker_pool.get_running_uuids()\n    \n    return {\n        'count': status['worker_count'],\n        'type': status['worker_type'],\n        'active_workers': len(running_uuids),\n        'processing_watches': running_uuids,\n        'loop_running': status.get('async_loop_running', None)\n    }\n\n\n# We use the whole watch object from the store/JSON so we can see if there's some related status in terms of a thread\n# running or something similar.\n@app.template_filter('format_last_checked_time')\ndef _jinja2_filter_datetime(watch_obj, format=\"%Y-%m-%d %H:%M:%S\"):\n\n    if watch_obj['last_checked'] == 0:\n        return gettext('Not yet')\n\n    locale = get_timeago_locale(str(get_locale()))\n    try:\n        return timeago.format(int(watch_obj['last_checked']), time.time(), locale)\n    except:\n        # Fallback to English if locale not supported by timeago\n        return timeago.format(int(watch_obj['last_checked']), time.time(), 'en')\n\n@app.template_filter('format_timestamp_timeago')\ndef _jinja2_filter_datetimestamp(timestamp, format=\"%Y-%m-%d %H:%M:%S\"):\n    if not timestamp:\n        return gettext('Not yet')\n\n    locale = get_timeago_locale(str(get_locale()))\n    try:\n        return timeago.format(int(timestamp), time.time(), locale)\n    except:\n        # Fallback to English if locale not supported by timeago\n        return timeago.format(int(timestamp), time.time(), 'en')\n\n\n@app.template_filter('pagination_slice')\ndef _jinja2_filter_pagination_slice(arr, skip):\n    per_page = datastore.data['settings']['application'].get('pager_size', 50)\n    if per_page:\n        return arr[skip:skip + per_page]\n\n    return arr\n\n@app.template_filter('format_seconds_ago')\ndef _jinja2_filter_seconds_precise(timestamp):\n    if timestamp == False:\n        return gettext('Not yet')\n\n    return format(int(time.time()-timestamp), ',d')\n\n@app.template_filter('format_duration')\ndef _jinja2_filter_format_duration(seconds):\n    \"\"\"Format a duration in seconds into human readable string like '5 days, 3 hours, 30 minutes'\"\"\"\n    from datetime import timedelta\n\n    if not seconds or seconds < 0:\n        return gettext('0 seconds')\n\n    td = timedelta(seconds=int(seconds))\n\n    # Calculate components\n    years = td.days // 365\n    remaining_days = td.days % 365\n    months = remaining_days // 30\n    remaining_days = remaining_days % 30\n    weeks = remaining_days // 7\n    days = remaining_days % 7\n\n    hours = td.seconds // 3600\n    minutes = (td.seconds % 3600) // 60\n    secs = td.seconds % 60\n\n    # Build parts list\n    parts = []\n    if years > 0:\n        parts.append(f\"{years} {gettext('year') if years == 1 else gettext('years')}\")\n    if months > 0:\n        parts.append(f\"{months} {gettext('month') if months == 1 else gettext('months')}\")\n    if weeks > 0:\n        parts.append(f\"{weeks} {gettext('week') if weeks == 1 else gettext('weeks')}\")\n    if days > 0:\n        parts.append(f\"{days} {gettext('day') if days == 1 else gettext('days')}\")\n    if hours > 0:\n        parts.append(f\"{hours} {gettext('hour') if hours == 1 else gettext('hours')}\")\n    if minutes > 0:\n        parts.append(f\"{minutes} {gettext('minute') if minutes == 1 else gettext('minutes')}\")\n    if secs > 0 or not parts:\n        parts.append(f\"{secs} {gettext('second') if secs == 1 else gettext('seconds')}\")\n\n    return \", \".join(parts)\n\n@app.template_filter('fetcher_status_icons')\ndef _jinja2_filter_fetcher_status_icons(fetcher_name):\n    \"\"\"Get status icon HTML for a given fetcher.\n\n    This filter checks both built-in fetchers and plugin fetchers for status icons.\n\n    Args:\n        fetcher_name: The fetcher name (e.g., 'html_webdriver', 'html_js_zyte')\n\n    Returns:\n        str: HTML string containing status icon elements\n    \"\"\"\n    from changedetectionio import content_fetchers\n    from changedetectionio.pluggy_interface import collect_fetcher_status_icons\n    from markupsafe import Markup\n    from flask import url_for\n\n    icon_data = None\n\n    # First check if it's a plugin fetcher (plugins have priority)\n    plugin_icon_data = collect_fetcher_status_icons(fetcher_name)\n    if plugin_icon_data:\n        icon_data = plugin_icon_data\n    # Check if it's a built-in fetcher\n    elif hasattr(content_fetchers, fetcher_name):\n        fetcher_class = getattr(content_fetchers, fetcher_name)\n        if hasattr(fetcher_class, 'get_status_icon_data'):\n            icon_data = fetcher_class.get_status_icon_data()\n\n    # Build HTML from icon data\n    if icon_data and isinstance(icon_data, dict):\n        # Use 'group' from icon_data if specified, otherwise default to 'images'\n        group = icon_data.get('group', 'images')\n\n        # Try to use url_for, but fall back to manual URL building if endpoint not registered yet\n        try:\n            icon_url = url_for('static_content', group=group, filename=icon_data['filename'])\n        except:\n            # Fallback: build URL manually respecting APPLICATION_ROOT\n            from flask import request\n            app_root = request.script_root if hasattr(request, 'script_root') else ''\n            icon_url = f\"{app_root}/static/{group}/{icon_data['filename']}\"\n\n        style_attr = f' style=\"{icon_data[\"style\"]}\"' if icon_data.get('style') else ''\n        html = f'<img class=\"status-icon\" src=\"{icon_url}\" alt=\"{icon_data[\"alt\"]}\" title=\"{icon_data[\"title\"]}\"{style_attr}>'\n        return Markup(html)\n\n    return ''\n\n_RE_SANITIZE_TAG = re.compile(r'[^a-zA-Z0-9]')\n\n@app.template_filter('sanitize_tag_class')\ndef _jinja2_filter_sanitize_tag_class(tag_title):\n    \"\"\"Sanitize a tag title to create a valid CSS class name.\n    Removes all non-alphanumeric characters and converts to lowercase.\n\n    Args:\n        tag_title: The tag title string\n\n    Returns:\n        str: A sanitized string suitable for use as a CSS class name\n    \"\"\"\n    # Remove all non-alphanumeric characters and convert to lowercase\n    sanitized = _RE_SANITIZE_TAG.sub('', tag_title).lower()\n    # Ensure it starts with a letter (CSS requirement)\n    if sanitized and not sanitized[0].isalpha():\n        sanitized = 'tag' + sanitized\n    return sanitized if sanitized else 'tag'\n\n# Import login_optionally_required from auth_decorator\nfrom changedetectionio.auth_decorator import login_optionally_required\n\n# When nobody is logged in Flask-Login's current_user is set to an AnonymousUser object.\nclass User(flask_login.UserMixin):\n    id=None\n\n    def set_password(self, password):\n        return True\n    def get_user(self, email=\"defaultuser@changedetection.io\"):\n        return self\n    def is_authenticated(self):\n        return True\n    def is_active(self):\n        return True\n    def is_anonymous(self):\n        return False\n    def get_id(self):\n        return str(self.id)\n\n    # Compare given password against JSON store or Env var\n    def check_password(self, password):\n        import base64\n        import hashlib\n\n        # Can be stored in env (for deployments) or in the general configs\n        raw_salt_pass = os.getenv(\"SALTED_PASS\", False)\n\n        if not raw_salt_pass:\n            raw_salt_pass = datastore.data['settings']['application'].get('password')\n\n        raw_salt_pass = base64.b64decode(raw_salt_pass)\n        salt_from_storage = raw_salt_pass[:32]  # 32 is the length of the salt\n\n        # Use the exact same setup you used to generate the key, but this time put in the password to check\n        new_key = hashlib.pbkdf2_hmac(\n            'sha256',\n            password.encode('utf-8'),  # Convert the password to bytes\n            salt_from_storage,\n            100000\n        )\n        new_key = salt_from_storage + new_key\n\n        return new_key == raw_salt_pass\n\n    pass\n\n\ndef changedetection_app(config=None, datastore_o=None):\n    logger.trace(\"TRACE log is enabled\")\n\n    global datastore, socketio_server\n    datastore = datastore_o\n\n    # Set datastore reference in notification queue for all_muted checking\n    notification_q.set_datastore(datastore)\n\n    # Import and create a wrapper for is_safe_url that has access to app\n    from changedetectionio.is_safe_url import is_safe_url as _is_safe_url\n\n    def is_safe_url(target):\n        \"\"\"Wrapper for is_safe_url that passes the app instance\"\"\"\n        return _is_safe_url(target, app)\n\n    # so far just for read-only via tests, but this will be moved eventually to be the main source\n    # (instead of the global var)\n    app.config['DATASTORE'] = datastore_o\n\n    # Store batch mode flag to skip background threads when running in batch mode\n    app.config['batch_mode'] = config.get('batch_mode', False) if config else False\n\n    # Store the signal in the app config to ensure it's accessible everywhere\n    app.config['watch_check_update_SIGNAL'] = watch_check_update\n\n    login_manager = flask_login.LoginManager(app)\n    login_manager.login_view = 'login'\n    app.secret_key = init_app_secret(config['datastore_path'])\n\n    # Initialize Flask-Babel for i18n support\n    available_languages = get_available_languages()\n    language_codes = get_language_codes()\n\n    _locale_aliases = {\n        'zh-TW': 'zh_Hant_TW',  # Traditional Chinese: browser sends zh-TW, we use zh_Hant_TW\n        'zh_TW': 'zh_Hant_TW',  # Also handle underscore variant\n    }\n    _locale_match_list = language_codes + list(_locale_aliases.keys())\n\n    def get_locale():\n        # 1. Try to get locale from session (user explicitly selected)\n        if 'locale' in session:\n            return session['locale']\n\n        # 2. Fall back to Accept-Language header\n        browser_locale = request.accept_languages.best_match(_locale_match_list)\n        # 3. Map browser locale to our internal locale if needed\n        return _locale_aliases.get(browser_locale, browser_locale)\n\n    # Initialize Babel with locale selector\n    babel = Babel(app, locale_selector=get_locale)\n\n    # Make i18n functions available to templates\n    app.jinja_env.globals.update(\n        _=gettext,\n        get_locale=get_locale,\n        get_flag_for_locale=get_flag_for_locale,\n        available_languages=available_languages\n    )\n\n    # Set up a request hook to check authentication for all routes\n    @app.before_request\n    def check_authentication():\n        has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv(\"SALTED_PASS\", False)\n\n        if has_password_enabled and not flask_login.current_user.is_authenticated:\n            # Permitted\n            if request.endpoint and request.endpoint == 'static_content' and request.view_args:\n                # Handled by static_content handler\n                return None\n            # Permitted - static flag icons need to load on login page\n            elif request.endpoint and request.endpoint == 'static_flags':\n                return None\n            # Permitted - language selection should work on login page\n            elif request.endpoint and request.endpoint == 'set_language':\n                return None\n            # Permitted\n            elif request.endpoint and 'login' in request.endpoint:\n                return None\n            elif request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):\n                return None\n            elif request.method in flask_login.config.EXEMPT_METHODS:\n                return None\n            elif app.config.get('LOGIN_DISABLED'):\n                return None\n            # RSS access with token is allowed\n            elif request.endpoint and 'rss.feed' in request.endpoint:\n                return None\n            # Socket.IO routes - need separate handling\n            elif request.path.startswith('/socket.io/'):\n                return None\n            # API routes - use their own auth mechanism (@auth.check_token)\n            elif request.path.startswith('/api/'):\n                return None\n            else:\n                return login_manager.unauthorized()\n\n\n    watch_api.add_resource(WatchHistoryDiff,\n                           '/api/v1/watch/<uuid_str:uuid>/difference/<string:from_timestamp>/<string:to_timestamp>',\n                           resource_class_kwargs={'datastore': datastore})\n    watch_api.add_resource(WatchSingleHistory,\n                           '/api/v1/watch/<uuid_str:uuid>/history/<string:timestamp>',\n                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})\n    watch_api.add_resource(WatchFavicon,\n                           '/api/v1/watch/<uuid_str:uuid>/favicon',\n                           resource_class_kwargs={'datastore': datastore})\n    watch_api.add_resource(WatchHistory,\n                           '/api/v1/watch/<uuid_str:uuid>/history',\n                           resource_class_kwargs={'datastore': datastore})\n\n    watch_api.add_resource(CreateWatch, '/api/v1/watch',\n                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})\n\n    watch_api.add_resource(Watch, '/api/v1/watch/<uuid_str:uuid>',\n                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})\n\n    watch_api.add_resource(SystemInfo, '/api/v1/systeminfo',\n                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})\n\n    watch_api.add_resource(Import,\n                           '/api/v1/import',\n                           resource_class_kwargs={'datastore': datastore})\n\n    watch_api.add_resource(Tags, '/api/v1/tags',\n                           resource_class_kwargs={'datastore': datastore})\n\n    watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/<uuid_str:uuid>',\n                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})\n                           \n    watch_api.add_resource(Search, '/api/v1/search',\n                           resource_class_kwargs={'datastore': datastore})\n\n    watch_api.add_resource(Notifications, '/api/v1/notifications',\n                           resource_class_kwargs={'datastore': datastore})\n\n    watch_api.add_resource(Spec, '/api/v1/full-spec')\n\n    @login_manager.user_loader\n    def user_loader(email):\n        user = User()\n        user.get_user(email)\n        return user\n\n    @login_manager.unauthorized_handler\n    def unauthorized_handler():\n        # Pass the current request path so users are redirected back after login\n        return redirect(url_for('login', redirect=request.path))\n\n    @app.route('/logout')\n    def logout():\n        flask_login.logout_user()\n\n        # Check if there's a redirect parameter to return to after re-login\n        redirect_url = request.args.get('redirect')\n\n        # If redirect is provided and safe, pass it to login page\n        if redirect_url and is_safe_url(redirect_url):\n            return redirect(url_for('login', redirect=redirect_url))\n\n        # Otherwise just go to watchlist\n        return redirect(url_for('watchlist.index'))\n\n    @app.route('/set-language/<locale>')\n    def set_language(locale):\n        \"\"\"Set the user's preferred language in the session\"\"\"\n        if not request.cookies:\n            logger.error(\"Cannot set language without session cookie\")\n            flash(\"Cannot set language without session cookie\", 'error')\n            return redirect(url_for('watchlist.index'))\n\n        # Validate the locale against available languages\n        if locale in language_codes:\n            # Make session permanent so language preference persists across browser sessions\n            # NOTE: This is the Flask session cookie (separate from Flask-Login's remember-me auth cookie)\n            session.permanent = True\n            session['locale'] = locale\n\n            # CRITICAL: Flask-Babel caches the locale in the request context (ctx.babel_locale)\n            # We must refresh to clear this cache so the new locale takes effect immediately\n            # This is especially important for tests where multiple requests happen rapidly\n            from flask_babel import refresh\n            refresh()\n        else:\n            logger.error(f\"Invalid locale {locale}, available: {language_codes}\")\n\n        # Check if there's a redirect parameter to return to the same page\n        redirect_url = request.args.get('redirect')\n\n        # If redirect is provided and safe, use it\n        if redirect_url and is_safe_url(redirect_url):\n            return redirect(redirect_url)\n\n        # Otherwise redirect to watchlist\n        return redirect(url_for('watchlist.index'))\n\n    # https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39\n    # You can divide up the stuff like this\n    @app.route('/login', methods=['GET', 'POST'])\n    def login():\n        # Extract and validate the redirect parameter\n        redirect_url = request.args.get('redirect') or request.form.get('redirect')\n\n        # Validate the redirect URL - default to watchlist if invalid\n        if redirect_url and is_safe_url(redirect_url):\n            validated_redirect = redirect_url\n        else:\n            validated_redirect = url_for('watchlist.index')\n\n        if request.method == 'GET':\n            if flask_login.current_user.is_authenticated:\n                # Already logged in - redirect immediately to the target\n                flash(gettext(\"Already logged in\"))\n                return redirect(validated_redirect)\n            flash(gettext(\"You must be logged in, please log in.\"), 'error')\n            output = render_template(\"login.html\", redirect_url=validated_redirect)\n            return output\n\n        user = User()\n        user.id = \"defaultuser@changedetection.io\"\n\n        password = request.form.get('password')\n\n        if (user.check_password(password)):\n            flask_login.login_user(user, remember=True)\n            # Redirect to the validated URL after successful login\n            return redirect(validated_redirect)\n\n        else:\n            flash(gettext('Incorrect password'), 'error')\n\n        return redirect(url_for('login', redirect=redirect_url if redirect_url else None))\n\n    @app.before_request\n    def before_request_handle_cookie_x_settings():\n        # Set the auth cookie path if we're running as X-settings/X-Forwarded-Prefix\n        if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:\n            app.config['REMEMBER_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']\n            app.config['SESSION_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']\n        return None\n\n    @app.route(\"/static/flags/<path:flag_path>\", methods=['GET'])\n    def static_flags(flag_path):\n        \"\"\"Handle flag icon files with subdirectories\"\"\"\n        from flask import make_response\n        import re\n\n        # flag_path comes in as \"1x1/de.svg\" or \"4x3/de.svg\"\n        if re.match(r'^(1x1|4x3)/[a-z0-9-]+\\.svg$', flag_path.lower()):\n            # Reconstruct the path safely with additional validation\n            parts = flag_path.lower().split('/')\n            if len(parts) != 2:\n                abort(404)\n\n            subdir = parts[0]\n            svg_file = parts[1]\n\n            # Extra validation: ensure subdir is exactly 1x1 or 4x3\n            if subdir not in ['1x1', '4x3']:\n                abort(404)\n\n            # Extra validation: ensure svg_file only contains safe characters\n            if not re.match(r'^[a-z0-9-]+\\.svg$', svg_file):\n                abort(404)\n\n            try:\n                response = make_response(send_from_directory(f\"static/flags/{subdir}\", svg_file))\n                response.headers['Content-type'] = 'image/svg+xml'\n                response.headers['Cache-Control'] = 'max-age=86400, public'  # Cache for 24 hours\n                return response\n            except FileNotFoundError:\n                abort(404)\n        else:\n            abort(404)\n\n    @app.route(\"/static/<string:group>/<string:filename>\", methods=['GET'])\n    def static_content(group, filename):\n        from flask import make_response\n        import re\n\n        # Strict sanitization: only allow a-z, 0-9, and underscore (blocks .. and other traversal)\n        group = re.sub(r'[^a-z0-9_-]+', '', group.lower())\n        filename = filename\n\n        # Additional safety: reject if sanitization resulted in empty strings\n        if not group or not filename:\n            abort(404)\n\n        if group == 'screenshot':\n            # Could be sensitive, follow password requirements\n            if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:\n                if not datastore.data['settings']['application'].get('shared_diff_access'):\n                    abort(403)\n\n            screenshot_filename = \"last-screenshot.png\" if not request.args.get('error_screenshot') else \"last-error-screenshot.png\"\n\n            # These files should be in our subdirectory\n            try:\n                # set nocache, set content-type\n                response = make_response(send_from_directory(os.path.join(datastore_o.datastore_path, filename), screenshot_filename))\n                response.headers['Content-type'] = 'image/png'\n                response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'\n                response.headers['Pragma'] = 'no-cache'\n                response.headers['Expires'] = 0\n                return response\n\n            except FileNotFoundError:\n                abort(404)\n\n        if group == 'favicon':\n            # Could be sensitive, follow password requirements\n            if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:\n                abort(403)\n            # Get the watch object\n            watch = datastore.data['watching'].get(filename)\n            if not watch:\n                abort(404)\n\n            favicon_filename = watch.get_favicon_filename()\n            if favicon_filename:\n                # Use cached MIME type detection\n                filepath = os.path.join(watch.data_dir, favicon_filename)\n                mime = get_favicon_mime_type(filepath)\n\n                response = make_response(send_from_directory(watch.data_dir, favicon_filename))\n                response.headers['Content-type'] = mime\n                response.headers['Cache-Control'] = 'max-age=300, must-revalidate'  # Cache for 5 minutes, then revalidate\n                return response\n\n        if group == 'visual_selector_data':\n            # Could be sensitive, follow password requirements\n            if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:\n                abort(403)\n\n            # These files should be in our subdirectory\n            try:\n                # set nocache, set content-type,\n                # `filename` is actually directory UUID of the watch\n                watch_directory = str(os.path.join(datastore_o.datastore_path, filename))\n                response = None\n                if os.path.isfile(os.path.join(watch_directory, \"elements.deflate\")):\n                    response = make_response(send_from_directory(watch_directory, \"elements.deflate\"))\n                    response.headers['Content-Type'] = 'application/json'\n                    response.headers['Content-Encoding'] = 'deflate'\n                else:\n                    logger.error(f'Request elements.deflate at \"{watch_directory}\" but was not found.')\n                    abort(404)\n\n                if response:\n                    response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'\n                    response.headers['Pragma'] = 'no-cache'\n                    response.headers['Expires'] = \"0\"\n\n                return response\n\n            except FileNotFoundError:\n                abort(404)\n\n        # Handle plugin group specially\n        if group == 'plugin':\n            # Serve files from plugin static directories\n            from changedetectionio.pluggy_interface import plugin_manager\n            import os as os_check\n\n            for plugin_name, plugin_obj in plugin_manager.list_name_plugin():\n                if hasattr(plugin_obj, 'plugin_static_path'):\n                    try:\n                        static_path = plugin_obj.plugin_static_path()\n                        if static_path and os_check.path.isdir(static_path):\n                            # Check if file exists in plugin's static directory\n                            plugin_file_path = os_check.path.join(static_path, filename)\n                            if os_check.path.isfile(plugin_file_path):\n                                # Found the file in a plugin\n                                response = make_response(send_from_directory(static_path, filename))\n                                response.headers['Cache-Control'] = 'max-age=3600, public'  # Cache for 1 hour\n                                return response\n                    except Exception as e:\n                        logger.debug(f\"Error checking plugin {plugin_name} for static file: {e}\")\n                        pass\n\n            # File not found in any plugin\n            abort(404)\n\n        # These files should be in our subdirectory\n        try:\n            return send_from_directory(f\"static/{group}\", path=filename)\n        except FileNotFoundError:\n            abort(404)\n\n\n    import changedetectionio.blueprint.browser_steps as browser_steps\n    app.register_blueprint(browser_steps.construct_blueprint(datastore), url_prefix='/browser-steps')\n\n    from changedetectionio.blueprint.imports import construct_blueprint as construct_import_blueprint\n    app.register_blueprint(construct_import_blueprint(datastore, update_q, queuedWatchMetaData), url_prefix='/imports')\n\n    import changedetectionio.blueprint.price_data_follower as price_data_follower\n    app.register_blueprint(price_data_follower.construct_blueprint(datastore, update_q), url_prefix='/price_data_follower')\n\n    import changedetectionio.blueprint.tags as tags\n    app.register_blueprint(tags.construct_blueprint(datastore), url_prefix='/tags')\n\n    import changedetectionio.blueprint.check_proxies as check_proxies\n    app.register_blueprint(check_proxies.construct_blueprint(datastore=datastore), url_prefix='/check_proxy')\n\n    import changedetectionio.blueprint.backups as backups\n    app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups')\n\n    import changedetectionio.blueprint.settings as settings\n    app.register_blueprint(settings.construct_blueprint(datastore), url_prefix='/settings')\n\n    import changedetectionio.conditions.blueprint as conditions\n    app.register_blueprint(conditions.construct_blueprint(datastore), url_prefix='/conditions')\n\n    import changedetectionio.blueprint.rss.blueprint as rss\n    app.register_blueprint(rss.construct_blueprint(datastore), url_prefix='/rss')\n\n    # watchlist UI buttons etc\n    import changedetectionio.blueprint.ui as ui\n    app.register_blueprint(ui.construct_blueprint(datastore, update_q, worker_pool, queuedWatchMetaData, watch_check_update))\n\n    import changedetectionio.blueprint.watchlist as watchlist\n    app.register_blueprint(watchlist.construct_blueprint(datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData), url_prefix='')\n\n    # Initialize Socket.IO server conditionally based on settings\n    socket_io_enabled = datastore.data['settings']['application'].get('ui', {}).get('socket_io_enabled', True)\n    if socket_io_enabled and app.config.get('batch_mode'):\n        socket_io_enabled = False\n    if socket_io_enabled:\n        from changedetectionio.realtime.socket_server import init_socketio\n        global socketio_server\n        socketio_server = init_socketio(app, datastore)\n        logger.info(\"Socket.IO server initialized\")\n    else:\n        logger.info(\"Socket.IO server disabled via settings\")\n        socketio_server = None\n\n    # Memory cleanup endpoint\n    @app.route('/gc-cleanup', methods=['GET'])\n    @login_optionally_required\n    def gc_cleanup():\n        from changedetectionio.gc_cleanup import memory_cleanup\n        from flask import jsonify\n\n        result = memory_cleanup(app)\n        return jsonify({\"status\": \"success\", \"message\": \"Memory cleanup completed\", \"result\": result})\n\n    # Worker health check endpoint\n    @app.route('/worker-health', methods=['GET'])\n    @login_optionally_required\n    def worker_health():\n        from flask import jsonify\n        \n        expected_workers = int(os.getenv(\"FETCH_WORKERS\", datastore.data['settings']['requests']['workers']))\n        \n        # Get basic status\n        status = worker_pool.get_worker_status()\n        \n        # Perform health check\n        health_result = worker_pool.check_worker_health(\n            expected_count=expected_workers,\n            update_q=update_q,\n            notification_q=notification_q,\n            app=app,\n            datastore=datastore\n        )\n        \n        return jsonify({\n            \"status\": \"success\",\n            \"worker_status\": status,\n            \"health_check\": health_result,\n            \"expected_workers\": expected_workers\n        })\n\n    # Queue status endpoint\n    @app.route('/queue-status', methods=['GET'])\n    @login_optionally_required\n    def queue_status():\n        from flask import jsonify, request\n        \n        # Get specific UUID position if requested\n        target_uuid = request.args.get('uuid')\n        \n        if target_uuid:\n            position_info = update_q.get_uuid_position(target_uuid)\n            return jsonify({\n                \"status\": \"success\",\n                \"uuid\": target_uuid,\n                \"queue_position\": position_info\n            })\n        else:\n            # Get pagination parameters\n            limit = request.args.get('limit', type=int)\n            offset = request.args.get('offset', type=int, default=0)\n            summary_only = request.args.get('summary', type=bool, default=False)\n            \n            if summary_only:\n                # Fast summary for large queues\n                summary = update_q.get_queue_summary()\n                return jsonify({\n                    \"status\": \"success\",\n                    \"queue_summary\": summary\n                })\n            else:\n                # Get queued items with pagination support\n                if limit is None:\n                    # Default limit for large queues to prevent performance issues\n                    queue_size = update_q.qsize()\n                    if queue_size > 100:\n                        limit = 50\n                        logger.warning(f\"Large queue ({queue_size} items) detected, limiting to {limit} items. Use ?limit=N for more.\")\n                \n                all_queued = update_q.get_all_queued_uuids(limit=limit, offset=offset)\n                return jsonify({\n                    \"status\": \"success\",\n                    \"queue_size\": update_q.qsize(),\n                    \"queued_data\": all_queued\n                })\n\n    # Start the async workers during app initialization\n    # Can be overridden by ENV or use the default settings\n    n_workers = int(os.getenv(\"FETCH_WORKERS\", datastore.data['settings']['requests']['workers']))\n    logger.info(f\"Starting {n_workers} workers during app initialization\")\n    worker_pool.start_workers(n_workers, update_q, notification_q, app, datastore)\n\n    # Skip background threads in batch mode (just process queue and exit)\n    batch_mode = app.config.get('batch_mode', False)\n    if not batch_mode:\n        # @todo handle ctrl break\n        ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks, daemon=True, name=\"TickerThread-ScheduleChecker\").start()\n\n        # Start configurable number of notification workers (default 1)\n        notification_workers = int(os.getenv(\"NOTIFICATION_WORKERS\", \"1\"))\n        for i in range(notification_workers):\n            threading.Thread(\n                target=notification_runner,\n                args=(i,),\n                daemon=True,\n                name=f\"NotificationRunner-{i}\"\n            ).start()\n        logger.info(f\"Started {notification_workers} notification worker(s)\")\n\n        in_pytest = \"pytest\" in sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ\n        # Check for new release version, but not when running in test/build or pytest\n        if not os.getenv(\"GITHUB_REF\", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest:\n            threading.Thread(target=check_for_new_version, daemon=True, name=\"VersionChecker\").start()\n    else:\n        logger.info(\"Batch mode: Skipping ticker thread, notification runner, and version checker\")\n\n    # Return the Flask app - the Socket.IO will be attached to it but initialized separately\n    # This avoids circular dependencies\n    return app\n\n\n# Check for new version and anonymous stats\ndef check_for_new_version():\n    import requests\n    import urllib3\n    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n\n    session = requests.Session()\n    session.verify = False\n\n    while not app.config.exit.is_set():\n        try:\n            r = session.post(\"https://changedetection.io/check-ver.php\",\n                              data={'version': __version__,\n                                    'app_guid': datastore.data['app_guid'],\n                                    'watch_count': len(datastore.data['watching'])\n                                    })\n        except:\n            pass\n\n        try:\n            if \"new_version\" in r.text:\n                app.config['NEW_VERSION_AVAILABLE'] = True\n        except:\n            pass\n\n        # Check daily\n        app.config.exit.wait(86400)\n\n\ndef notification_runner(worker_id=0):\n    global notification_debug_log\n    from datetime import datetime\n    import json\n    with app.app_context():\n        while not app.config.exit.is_set():\n            try:\n                # Multiple workers can run concurrently (configurable via NOTIFICATION_WORKERS)\n                n_object = notification_q.get(block=False)\n            except queue.Empty:\n                app.config.exit.wait(1)\n\n            else:\n\n                now = datetime.now()\n                sent_obj = None\n\n                try:\n                    from changedetectionio.notification.handler import process_notification\n\n                    # Fallback to system config if not set\n                    if not n_object.get('notification_body') and datastore.data['settings']['application'].get('notification_body'):\n                        n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')\n\n                    if not n_object.get('notification_title') and datastore.data['settings']['application'].get('notification_title'):\n                        n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title')\n\n                    if not n_object.get('notification_format') and datastore.data['settings']['application'].get('notification_format'):\n                        n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format')\n                    if n_object.get('notification_urls', {}):\n                        sent_obj = process_notification(n_object, datastore)\n\n                except Exception as e:\n                    logger.error(f\"Notification worker {worker_id} - Watch URL: {n_object['watch_url']}  Error {str(e)}\")\n\n                    # UUID wont be present when we submit a 'test' from the global settings\n                    if 'uuid' in n_object:\n                        datastore.update_watch(uuid=n_object['uuid'],\n                                               update_obj={'last_notification_error': \"Notification error detected, goto notification log.\"})\n\n                    log_lines = str(e).splitlines()\n                    notification_debug_log += log_lines\n\n                    with app.app_context():\n                        app.config['watch_check_update_SIGNAL'].send(app_context=app, watch_uuid=n_object.get('uuid'))\n\n                # Process notifications\n                notification_debug_log+= [\"{} - SENDING - {}\".format(now.strftime(\"%c\"), json.dumps(sent_obj))]\n                # Trim the log length\n                notification_debug_log = notification_debug_log[-100:]\n\n\n\n# Threaded runner, look for new watches to feed into the Queue.\ndef ticker_thread_check_time_launch_checks():\n    import random\n    proxy_last_called_time = {}\n    last_health_check = 0\n\n    recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))\n    logger.debug(f\"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}\")\n\n    # Workers are now started during app initialization, not here\n    WAIT_TIME_BETWEEN_LOOP = 1.0 if not IN_PYTEST else 0.01\n    if IN_PYTEST:\n        # The time between loops should be less than the first .sleep/wait in def wait_for_all_checks() of tests/util.py\n        logger.warning(f\"Looks like we're in PYTEST! Setting time between searching for items to add to the queue to {WAIT_TIME_BETWEEN_LOOP}s\")\n\n    while not app.config.exit.is_set():\n\n        # Periodic worker health check (every 60 seconds)\n        now = time.time()\n        if now - last_health_check > 60:\n            expected_workers = int(os.getenv(\"FETCH_WORKERS\", datastore.data['settings']['requests']['workers']))\n            health_result = worker_pool.check_worker_health(\n                expected_count=expected_workers,\n                update_q=update_q,\n                notification_q=notification_q,\n                app=app,\n                datastore=datastore\n            )\n            \n            if health_result['status'] != 'healthy':\n                logger.warning(f\"Worker health check: {health_result['message']}\")\n\n            last_health_check = now\n\n        # Check if all checks are paused\n        if datastore.data['settings']['application'].get('all_paused', False):\n            app.config.exit.wait(1)\n            continue\n\n        # Get a list of watches by UUID that are currently fetching data\n        running_uuids = worker_pool.get_running_uuids()\n\n        # Build set of queued UUIDs once for O(1) lookup instead of O(n) per watch\n        queued_uuids = {q_item.item['uuid'] for q_item in update_q.queue}\n\n        # Re #232 - Deepcopy the data incase it changes while we're iterating through it all\n        watch_uuid_list = []\n        while True:\n            try:\n                # Get a list of watches sorted by last_checked, [1] because it gets passed a tuple\n                # This is so we examine the most over-due first\n                for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked',0)):\n                    watch_uuid_list.append(k[0])\n\n            except RuntimeError as e:\n                # RuntimeError: dictionary changed size during iteration\n                time.sleep(0.1)\n                watch_uuid_list = []\n            else:\n                break\n\n        recheck_time_system_seconds = int(datastore.threshold_seconds)\n\n        # Check for watches outside of the time threshold to put in the thread queue.\n        for watch_index, uuid in enumerate(watch_uuid_list):\n            # Re #438 - Check queue size every 100 watches for CPU efficiency (not every watch)\n            if watch_index % 100 == 0:\n                current_queue_size = update_q.qsize()\n                if current_queue_size >= MAX_QUEUE_SIZE:\n                    logger.debug(f\"Queue size limit reached ({current_queue_size}/{MAX_QUEUE_SIZE}), stopping scheduler this iteration.\")\n                    break\n\n            now = time.time()\n            watch = datastore.data['watching'].get(uuid)\n            if not watch:\n                logger.error(f\"Watch: {uuid} no longer present.\")\n                continue\n\n            # No need todo further processing if it's paused\n            if watch['paused']:\n                continue\n\n            # @todo - Maybe make this a hook?\n            # Time schedule limit - Decide between watch or global settings\n            scheduler_source = None\n            if watch.get('time_between_check_use_default'):\n                time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})\n                scheduler_source = 'system/global settings'\n\n            else:\n                time_schedule_limit = watch.get('time_schedule_limit')\n                scheduler_source = 'watch'\n\n            tz_name = datastore.data['settings']['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC').strip())\n\n            if time_schedule_limit and time_schedule_limit.get('enabled'):\n                logger.trace(f\"{uuid} Time scheduler - Using scheduler settings from {scheduler_source}\")\n                try:\n                    result = is_within_schedule(time_schedule_limit=time_schedule_limit,\n                                                default_tz=tz_name\n                                                )\n                    if not result:\n                        logger.trace(f\"{uuid} Time scheduler - not within schedule skipping.\")\n                        continue\n                except Exception as e:\n                    logger.error(\n                        f\"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}\")\n                    return False\n\n            # If they supplied an individual entry minutes to threshold.\n            threshold = recheck_time_system_seconds if watch.get('time_between_check_use_default') else watch.threshold_seconds()\n\n            # #580 - Jitter plus/minus amount of time to make the check seem more random to the server\n            jitter = datastore.data['settings']['requests'].get('jitter_seconds', 0)\n            if jitter > 0:\n                if watch.jitter_seconds == 0:\n                    watch.jitter_seconds = random.uniform(-abs(jitter), jitter)\n\n            seconds_since_last_recheck = now - watch['last_checked']\n\n            if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds:\n                if not uuid in running_uuids and uuid not in queued_uuids:\n\n                    # Proxies can be set to have a limit on seconds between which they can be called\n                    watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid)\n                    if watch_proxy and watch_proxy in list(datastore.proxy_list.keys()):\n                        # Proxy may also have some threshold minimum\n                        proxy_list_reuse_time_minimum = int(datastore.proxy_list.get(watch_proxy, {}).get('reuse_time_minimum', 0))\n                        if proxy_list_reuse_time_minimum:\n                            proxy_last_used_time = proxy_last_called_time.get(watch_proxy, 0)\n                            time_since_proxy_used = int(time.time() - proxy_last_used_time)\n                            if time_since_proxy_used < proxy_list_reuse_time_minimum:\n                                # Not enough time difference reached, skip this watch\n                                logger.debug(f\"> Skipped UUID {uuid} \"\n                                        f\"using proxy '{watch_proxy}', not \"\n                                        f\"enough time between proxy requests \"\n                                        f\"{time_since_proxy_used}s/{proxy_list_reuse_time_minimum}s\")\n                                continue\n                            else:\n                                # Record the last used time\n                                proxy_last_called_time[watch_proxy] = int(time.time())\n\n                    # Use Epoch time as priority, so we get a \"sorted\" PriorityQueue, but we can still push a priority 1 into it.\n                    priority = int(time.time())\n\n                    # Into the queue with you\n                    queued_successfully = worker_pool.queue_item_async_safe(update_q,\n                                                                               queuedWatchMetaData.PrioritizedItem(priority=priority,\n                                                                                                                   item={'uuid': uuid})\n                                                                               )\n                    if queued_successfully:\n                        logger.debug(\n                            f\"> Queued watch UUID {uuid} \"\n                            f\"last checked at {watch['last_checked']} \"\n                            f\"queued at {now:0.2f} priority {priority} \"\n                            f\"jitter {watch.jitter_seconds:0.2f}s, \"\n                            f\"{now - watch['last_checked']:0.2f}s since last checked\")\n                    else:\n                        logger.critical(f\"CRITICAL: Failed to queue watch UUID {uuid} in ticker thread!\")\n                        \n                    # Reset for next time\n                    watch.jitter_seconds = 0\n\n        # Should be low so we can break this out in testing\n        app.config.exit.wait(WAIT_TIME_BETWEEN_LOOP)\n"
  },
  {
    "path": "changedetectionio/forms.py",
    "content": "import os\nimport re\nfrom loguru import logger\nfrom wtforms.widgets.core import TimeInput\nfrom flask_babel import lazy_gettext as _l, gettext\n\nfrom changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_TEMPLATE_TYPE_OPTIONS, RSS_TEMPLATE_HTML_DEFAULT\nfrom changedetectionio.conditions.form import ConditionFormRow\nfrom changedetectionio.notification_service import NotificationContextData\nfrom changedetectionio.strtobool import strtobool\nfrom changedetectionio import processors\n\nfrom wtforms import (\n    BooleanField,\n    Form,\n    Field,\n    FloatField,\n    IntegerField,\n    RadioField,\n    SelectField,\n    StringField,\n    SubmitField,\n    TextAreaField,\n    fields,\n    validators,\n    widgets\n)\nfrom flask_wtf.file import FileField, FileAllowed\nfrom wtforms.fields import FieldList\nfrom wtforms.utils import unset_value\n\nfrom wtforms.validators import ValidationError\n\nfrom changedetectionio.widgets import TernaryNoneBooleanField\n\n# default\n# each select <option data-enabled=\"enabled-0-0\"\nfrom changedetectionio.browser_steps.browser_steps import browser_step_ui_config\n\nfrom changedetectionio import html_tools, content_fetchers\n\nfrom changedetectionio.notification import (\n    valid_notification_formats,\n)\n\nfrom wtforms.fields import FormField\n\ndictfilt = lambda x, y: dict([ (i,x[i]) for i in x if i in set(y) ])\n\nvalid_method = {\n    'GET',\n    'POST',\n    'PUT',\n    'PATCH',\n    'DELETE',\n    'OPTIONS',\n}\n\ndefault_method = 'GET'\nallow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))\nREQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT=_l('At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.')\nREQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT=_l('At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.')\n\nclass StringListField(StringField):\n    widget = widgets.TextArea()\n\n    def _value(self):\n        if self.data:\n            # ignore empty lines in the storage\n            data = list(filter(lambda x: len(x.strip()), self.data))\n            # Apply strip to each line\n            data = list(map(lambda x: x.strip(), data))\n            return \"\\r\\n\".join(data)\n        else:\n            return u''\n\n    # incoming\n    def process_formdata(self, valuelist):\n        if valuelist and len(valuelist[0].strip()):\n            # Remove empty strings, stripping and splitting \\r\\n, only \\n etc.\n            self.data = valuelist[0].splitlines()\n            # Remove empty lines from the final data\n            self.data = list(filter(lambda x: len(x.strip()), self.data))\n        else:\n            self.data = []\n\n\nclass SaltyPasswordField(StringField):\n    widget = widgets.PasswordInput()\n    encrypted_password = \"\"\n\n    def build_password(self, password):\n        import base64\n        import hashlib\n        import secrets\n\n        # Make a new salt on every new password and store it with the password\n        salt = secrets.token_bytes(32)\n\n        key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)\n        store = base64.b64encode(salt + key).decode('ascii')\n\n        return store\n\n    # incoming\n    def process_formdata(self, valuelist):\n        if valuelist:\n            # Be really sure it's non-zero in length\n            if len(valuelist[0].strip()) > 0:\n                self.encrypted_password = self.build_password(valuelist[0])\n                self.data = \"\"\n        else:\n            self.data = False\n\nclass StringTagUUID(StringField):\n\n   # process_formdata(self, valuelist) handled manually in POST handler\n\n    # Is what is shown when field <input> is rendered\n    def _value(self):\n        # Tag UUID to name, on submit it will convert it back (in the submit handler of init.py)\n        if self.data and type(self.data) is list:\n            tag_titles = []\n            for i in self.data:\n                tag = self.datastore.data['settings']['application']['tags'].get(i)\n                if tag:\n                    tag_title = tag.get('title')\n                    if tag_title:\n                        tag_titles.append(tag_title)\n\n            return ', '.join(tag_titles)\n\n        if not self.data:\n            return ''\n\n        return 'error'\n\nclass TimeDurationForm(Form):\n    hours = SelectField(choices=[(f\"{i}\", f\"{i}\") for i in range(0, 25)], default=\"24\",  validators=[validators.Optional()])\n    minutes = SelectField(choices=[(f\"{i}\", f\"{i}\") for i in range(0, 60)], default=\"00\", validators=[validators.Optional()])\n\nclass TimeStringField(Field):\n    \"\"\"\n    A WTForms field for time inputs (HH:MM) that stores the value as a string.\n    \"\"\"\n    widget = TimeInput()  # Use the built-in time input widget\n\n    def _value(self):\n        \"\"\"\n        Returns the value for rendering in the form.\n        \"\"\"\n        return self.data if self.data is not None else \"\"\n\n    def process_formdata(self, valuelist):\n        \"\"\"\n        Processes the raw input from the form and stores it as a string.\n        \"\"\"\n        if valuelist:\n            time_str = valuelist[0]\n            # Simple validation for HH:MM format\n            if not time_str or len(time_str.split(\":\")) != 2:\n                raise ValidationError(_l(\"Invalid time format. Use HH:MM.\"))\n            self.data = time_str\n\n\nclass validateTimeZoneName(object):\n    \"\"\"\n       Flask wtform validators wont work with basic auth\n    \"\"\"\n\n    def __init__(self, message=None):\n        self.message = message\n\n    def __call__(self, form, field):\n        from zoneinfo import available_timezones\n        python_timezones = available_timezones()\n        if field.data and field.data not in python_timezones:\n            raise ValidationError(_l(\"Not a valid timezone name\"))\n\nclass ScheduleLimitDaySubForm(Form):\n    enabled = BooleanField(_l(\"not set\"), default=True)\n    start_time = TimeStringField(_l(\"Start At\"), default=\"00:00\", validators=[validators.Optional()])\n    duration = FormField(TimeDurationForm, label=_l(\"Run duration\"))\n\nclass ScheduleLimitForm(Form):\n    enabled = BooleanField(_l(\"Use time scheduler\"), default=False)\n    # Because the label for=\"\"\" doesnt line up/work with the actual checkbox\n    monday = FormField(ScheduleLimitDaySubForm, label=\"\")\n    tuesday = FormField(ScheduleLimitDaySubForm, label=\"\")\n    wednesday = FormField(ScheduleLimitDaySubForm, label=\"\")\n    thursday = FormField(ScheduleLimitDaySubForm, label=\"\")\n    friday = FormField(ScheduleLimitDaySubForm, label=\"\")\n    saturday = FormField(ScheduleLimitDaySubForm, label=\"\")\n    sunday = FormField(ScheduleLimitDaySubForm, label=\"\")\n\n    timezone = StringField(_l(\"Optional timezone to run in\"),\n                                  render_kw={\"list\": \"timezones\"},\n                                  validators=[validateTimeZoneName()]\n                                  )\n    def __init__(\n        self,\n        formdata=None,\n        obj=None,\n        prefix=\"\",\n        data=None,\n        meta=None,\n        **kwargs,\n    ):\n        super().__init__(formdata, obj, prefix, data, meta, **kwargs)\n        self.monday.form.enabled.label.text=_l(\"Monday\")\n        self.tuesday.form.enabled.label.text = _l(\"Tuesday\")\n        self.wednesday.form.enabled.label.text = _l(\"Wednesday\")\n        self.thursday.form.enabled.label.text = _l(\"Thursday\")\n        self.friday.form.enabled.label.text = _l(\"Friday\")\n        self.saturday.form.enabled.label.text = _l(\"Saturday\")\n        self.sunday.form.enabled.label.text = _l(\"Sunday\")\n\n\ndef validate_time_between_check_has_values(form):\n    \"\"\"\n    Custom validation function for TimeBetweenCheckForm.\n    Returns True if at least one time interval field has a value > 0.\n    \"\"\"\n    res = any([\n        form.weeks.data and int(form.weeks.data) > 0,\n        form.days.data and int(form.days.data) > 0,\n        form.hours.data and int(form.hours.data) > 0,\n        form.minutes.data and int(form.minutes.data) > 0,\n        form.seconds.data and int(form.seconds.data) > 0\n    ])\n\n    return res\n\n\nclass RequiredTimeInterval(object):\n    \"\"\"\n    WTForms validator that ensures at least one time interval field has a value > 0.\n    Use this with FormField(TimeBetweenCheckForm, validators=[RequiredTimeInterval()]).\n    \"\"\"\n    def __init__(self, message=None):\n        self.message = message or _l('At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.')\n\n    def __call__(self, form, field):\n        if not validate_time_between_check_has_values(field.form):\n            raise ValidationError(self.message)\n\n\nclass TimeBetweenCheckForm(Form):\n    weeks = IntegerField(_l('Weeks'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l(\"Should contain zero or more seconds\"))])\n    days = IntegerField(_l('Days'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l(\"Should contain zero or more seconds\"))])\n    hours = IntegerField(_l('Hours'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l(\"Should contain zero or more seconds\"))])\n    minutes = IntegerField(_l('Minutes'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l(\"Should contain zero or more seconds\"))])\n    seconds = IntegerField(_l('Seconds'), validators=[validators.Optional(), validators.NumberRange(min=0, message=_l(\"Should contain zero or more seconds\"))])\n    # @todo add total seconds minimum validatior = minimum_seconds_recheck_time\n\n    def __init__(self, formdata=None, obj=None, prefix=\"\", data=None, meta=None, **kwargs):\n        super().__init__(formdata, obj, prefix, data, meta, **kwargs)\n        self.require_at_least_one = kwargs.get('require_at_least_one', False)\n        self.require_at_least_one_message = kwargs.get('require_at_least_one_message', REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT)\n\n    def validate(self, **kwargs):\n        \"\"\"Custom validation that can optionally require at least one time interval.\"\"\"\n        # Run normal field validation first\n        if not super().validate(**kwargs):\n            return False\n\n        # Apply optional \"at least one\" validation\n        if self.require_at_least_one:\n            if not validate_time_between_check_has_values(self):\n                # Add error to the form's general errors (not field-specific)\n                if not hasattr(self, '_formdata_errors'):\n                    self._formdata_errors = []\n                self._formdata_errors.append(self.require_at_least_one_message)\n                return False\n\n        return True\n\n\nclass EnhancedFormField(FormField):\n    \"\"\"\n    An enhanced FormField that supports conditional validation with top-level error messages.\n    Adds a 'top_errors' property for validation errors at the FormField level.\n    \"\"\"\n\n    def __init__(self, form_class, label=None, validators=None, separator=\"-\",\n                 conditional_field=None, conditional_message=None, conditional_test_function=None, **kwargs):\n        \"\"\"\n        Initialize EnhancedFormField with optional conditional validation.\n\n        :param conditional_field: Name of the field this FormField depends on (e.g. 'time_between_check_use_default')\n        :param conditional_message: Error message to show when validation fails\n        :param conditional_test_function: Custom function to test if FormField has valid values.\n                                        Should take self.form as parameter and return True if valid.\n        \"\"\"\n        super().__init__(form_class, label, validators, separator, **kwargs)\n        self.top_errors = []\n        self.conditional_field = conditional_field\n        self.conditional_message = conditional_message or \"At least one field must have a value when not using defaults.\"\n        self.conditional_test_function = conditional_test_function\n\n    def validate(self, form, extra_validators=()):\n        \"\"\"\n        Custom validation that supports conditional logic and stores top-level errors.\n        \"\"\"\n        self.top_errors = []\n\n        # First run the normal FormField validation\n        base_valid = super().validate(form, extra_validators)\n\n        # Apply conditional validation if configured\n        if self.conditional_field and hasattr(form, self.conditional_field):\n            conditional_field_obj = getattr(form, self.conditional_field)\n\n            # If the conditional field is False/unchecked, check if this FormField has any values\n            if not conditional_field_obj.data:\n                # Use custom test function if provided, otherwise use generic fallback\n                if self.conditional_test_function:\n                    has_any_value = self.conditional_test_function(self.form)\n                else:\n                    # Generic fallback - check if any field has truthy data\n                    has_any_value = any(field.data for field in self.form if hasattr(field, 'data') and field.data)\n\n                if not has_any_value:\n                    self.top_errors.append(self.conditional_message)\n                    base_valid = False\n\n        return base_valid\n\n\nclass RequiredFormField(FormField):\n    \"\"\"\n    A FormField that passes require_at_least_one=True to TimeBetweenCheckForm.\n    Use this when you want the sub-form to always require at least one value.\n    \"\"\"\n\n    def __init__(self, form_class, label=None, validators=None, separator=\"-\", **kwargs):\n        super().__init__(form_class, label, validators, separator, **kwargs)\n\n    def process(self, formdata, data=unset_value, extra_filters=None):\n        if extra_filters:\n            raise TypeError(\n                \"FormField cannot take filters, as the encapsulated\"\n                \"data is not mutable.\"\n            )\n\n        if data is unset_value:\n            try:\n                data = self.default()\n            except TypeError:\n                data = self.default\n            self._obj = data\n\n        self.object_data = data\n\n        prefix = self.name + self.separator\n        # Pass require_at_least_one=True to the sub-form\n        if isinstance(data, dict):\n            self.form = self.form_class(formdata=formdata, prefix=prefix, require_at_least_one=True, **data)\n        else:\n            self.form = self.form_class(formdata=formdata, obj=data, prefix=prefix, require_at_least_one=True)\n\n    @property\n    def errors(self):\n        \"\"\"Include sub-form validation errors\"\"\"\n        form_errors = self.form.errors\n        # Add any general form errors to a special 'form' key\n        if hasattr(self.form, '_formdata_errors') and self.form._formdata_errors:\n            form_errors = dict(form_errors)  # Make a copy\n            form_errors['form'] = self.form._formdata_errors\n        return form_errors\n\n\n# Separated by  key:value\nclass StringDictKeyValue(StringField):\n    widget = widgets.TextArea()\n\n    def _value(self):\n        if self.data:\n            output = ''\n            for k, v in self.data.items():\n                output += f\"{k}: {v}\\r\\n\"\n            return output\n        else:\n            return ''\n\n    # incoming data processing + validation\n    def process_formdata(self, valuelist):\n        self.data = {}\n        errors = []\n        if valuelist:\n            # Remove empty strings (blank lines)\n            cleaned = [line.strip() for line in valuelist[0].split(\"\\n\") if line.strip()]\n            for idx, s in enumerate(cleaned, start=1):\n                if ':' not in s:\n                    errors.append(f\"Line {idx} is missing a ':' separator.\")\n                    continue\n                parts = s.split(':', 1)\n                key = parts[0].strip()\n                value = parts[1].strip()\n\n                if not key:\n                    errors.append(f\"Line {idx} has an empty key.\")\n                if not value:\n                    errors.append(f\"Line {idx} has an empty value.\")\n\n                self.data[key] = value\n\n        if errors:\n            raise ValidationError(\"Invalid input:\\n\" + \"\\n\".join(errors))\n\nclass ValidateContentFetcherIsReady(object):\n    \"\"\"\n    Validates that anything that looks like a regex passes as a regex\n    \"\"\"\n    def __init__(self, message=None):\n        self.message = message\n\n    def __call__(self, form, field):\n        return\n\n# AttributeError: module 'changedetectionio.content_fetcher' has no attribute 'extra_browser_unlocked<>ASDF213r123r'\n        # Better would be a radiohandler that keeps a reference to each class\n        # if field.data is not None and field.data != 'system':\n        #     klass = getattr(content_fetcher, field.data)\n        #     some_object = klass()\n        #     try:\n        #         ready = some_object.is_ready()\n        #\n        #     except urllib3.exceptions.MaxRetryError as e:\n        #         driver_url = some_object.command_executor\n        #         message = field.gettext('Content fetcher \\'%s\\' did not respond.' % (field.data))\n        #         message += '<br>' + field.gettext(\n        #             'Be sure that the selenium/webdriver runner is running and accessible via network from this container/host.')\n        #         message += '<br>' + field.gettext('Did you follow the instructions in the wiki?')\n        #         message += '<br><br>' + field.gettext('WebDriver Host: %s' % (driver_url))\n        #         message += '<br><a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver\">Go here for more information</a>'\n        #         message += '<br>'+field.gettext('Content fetcher did not respond properly, unable to use it.\\n %s' % (str(e)))\n        #\n        #         raise ValidationError(message)\n        #\n        #     except Exception as e:\n        #         message = field.gettext('Content fetcher \\'%s\\' did not respond properly, unable to use it.\\n %s')\n        #         raise ValidationError(message % (field.data, e))\n\n\nclass ValidateNotificationBodyAndTitleWhenURLisSet(object):\n    \"\"\"\n       Validates that they entered something in both notification title+body when the URL is set\n       Due to https://github.com/dgtlmoon/changedetection.io/issues/360\n       \"\"\"\n\n    def __init__(self, message=None):\n        self.message = message\n\n    def __call__(self, form, field):\n        if len(field.data):\n            if not len(form.notification_title.data) or not len(form.notification_body.data):\n                message = field.gettext('Notification Body and Title is required when a Notification URL is used')\n                raise ValidationError(message)\n\nclass ValidateAppRiseServers(object):\n    \"\"\"\n       Validates that each URL given is compatible with AppRise\n       \"\"\"\n\n    def __init__(self, message=None):\n        self.message = message\n\n    def __call__(self, form, field):\n        import apprise\n        from .notification.apprise_plugin.assets import apprise_asset\n        from .notification.apprise_plugin.custom_handlers import apprise_http_custom_handler  # noqa: F401\n        from changedetectionio.jinja2_custom import render as jinja_render\n\n        apobj = apprise.Apprise(asset=apprise_asset)\n\n        for server_url in field.data:\n            generic_notification_context_data = NotificationContextData()\n            # Make sure something is atleast in all those regular token fields\n            generic_notification_context_data.set_random_for_validation()\n\n            url = jinja_render(template_str=server_url.strip(), **generic_notification_context_data).strip()\n            if url.startswith(\"#\"):\n                continue\n\n            if not apobj.add(url):\n                message = field.gettext('\\'%s\\' is not a valid AppRise URL.' % (url))\n                raise ValidationError(message)\n\nclass ValidateJinja2Template(object):\n    \"\"\"\n    Validates that a {token} is from a valid set\n    \"\"\"\n    def __call__(self, form, field):\n        from changedetectionio.jinja2_custom import create_jinja_env\n        from jinja2 import BaseLoader, TemplateSyntaxError, UndefinedError\n        from jinja2.meta import find_undeclared_variables\n        import jinja2.exceptions\n\n        # Might be a list of text, or might be just text (like from the apprise url list)\n        joined_data = ' '.join(map(str, field.data)) if isinstance(field.data, list) else f\"{field.data}\"\n\n        try:\n            # Use the shared helper to create a properly configured environment\n            jinja2_env = create_jinja_env(loader=BaseLoader)\n\n            # Add notification tokens for validation\n            static_token_placeholders = NotificationContextData()\n            static_token_placeholders.set_random_for_validation()\n            jinja2_env.globals.update(static_token_placeholders)\n            if hasattr(field, 'extra_notification_tokens'):\n                jinja2_env.globals.update(field.extra_notification_tokens)\n\n            jinja2_env.from_string(joined_data).render()\n        except TemplateSyntaxError as e:\n            raise ValidationError(f\"This is not a valid Jinja2 template: {e}\") from e\n        except UndefinedError as e:\n            raise ValidationError(f\"A variable or function is not defined: {e}\") from e\n        except jinja2.exceptions.SecurityError as e:\n            raise ValidationError(f\"This is not a valid Jinja2 template: {e}\") from e\n\n        # Check for undeclared variables\n        ast = jinja2_env.parse(joined_data)\n        undefined = \", \".join(find_undeclared_variables(ast))\n        if undefined:\n            raise ValidationError(\n                f\"The following tokens used in the notification are not valid: {undefined}\"\n            )\n\nclass validateURL(object):\n\n    \"\"\"\n       Flask wtform validators wont work with basic auth\n    \"\"\"\n\n    def __init__(self, message=None):\n        self.message = message\n\n    def __call__(self, form, field):\n        # This should raise a ValidationError() or not\n        validate_url(field.data)\n\n\ndef validate_url(test_url):\n    from changedetectionio.validate_url import is_safe_valid_url\n    if not is_safe_valid_url(test_url):\n        # This should be wtforms.validators.\n        raise ValidationError('Watch protocol is not permitted or invalid URL format')\n\n\nclass ValidateSinglePythonRegexString(object):\n    def __init__(self, message=None):\n        self.message = message\n\n    def __call__(self, form, field):\n        try:\n            re.compile(field.data)\n        except re.error:\n            message = field.gettext('RegEx \\'%s\\' is not a valid regular expression.')\n            raise ValidationError(message % (field.data))\n\n\nclass ValidateListRegex(object):\n    \"\"\"\n    Validates that anything that looks like a regex passes as a regex\n    \"\"\"\n    def __init__(self, message=None):\n        self.message = message\n\n    def __call__(self, form, field):\n\n        for line in field.data:\n            if re.search(html_tools.PERL_STYLE_REGEX, line, re.IGNORECASE):\n                try:\n                    regex = html_tools.perl_style_slash_enclosed_regex_to_options(line)\n                    re.compile(regex)\n                except re.error:\n                    message = field.gettext('RegEx \\'%s\\' is not a valid regular expression.')\n                    raise ValidationError(message % (line))\n\n\nclass ValidateCSSJSONXPATHInput(object):\n    \"\"\"\n    Filter validation\n    @todo CSS validator ;)\n    \"\"\"\n\n    def __init__(self, message=None, allow_xpath=True, allow_json=True):\n        self.message = message\n        self.allow_xpath = allow_xpath\n        self.allow_json = allow_json\n\n    def __call__(self, form, field):\n\n        if isinstance(field.data, str):\n            data = [field.data]\n        else:\n            data = field.data\n\n        for line in data:\n        # Nothing to see here\n            if not len(line.strip()):\n                return\n\n            # Does it look like XPath?\n            if line.strip()[0] == '/' or line.strip().startswith('xpath:'):\n                if not self.allow_xpath:\n                    raise ValidationError(\"XPath not permitted in this field!\")\n                from lxml import etree, html\n                import elementpath\n                from changedetectionio.html_tools import SafeXPath3Parser\n                tree = html.fromstring(\"<html></html>\")\n                line = line.replace('xpath:', '')\n\n                try:\n                    elementpath.select(tree, line.strip(), parser=SafeXPath3Parser)\n                except elementpath.ElementPathError as e:\n                    message = field.gettext('\\'%s\\' is not a valid XPath expression. (%s)')\n                    raise ValidationError(message % (line, str(e)))\n                except:\n                    raise ValidationError(\"A system-error occurred when validating your XPath expression\")\n\n            if line.strip().startswith('xpath1:'):\n                if not self.allow_xpath:\n                    raise ValidationError(\"XPath not permitted in this field!\")\n                from lxml import etree, html\n                tree = html.fromstring(\"<html></html>\")\n                line = re.sub(r'^xpath1:', '', line)\n\n                try:\n                    tree.xpath(line.strip())\n                except etree.XPathEvalError as e:\n                    message = field.gettext('\\'%s\\' is not a valid XPath expression. (%s)')\n                    raise ValidationError(message % (line, str(e)))\n                except:\n                    raise ValidationError(\"A system-error occurred when validating your XPath expression\")\n\n            if 'json:' in line:\n                if not self.allow_json:\n                    raise ValidationError(\"JSONPath not permitted in this field!\")\n\n                from jsonpath_ng.exceptions import (\n                    JsonPathLexerError,\n                    JsonPathParserError,\n                )\n                from jsonpath_ng.ext import parse\n\n                input = line.replace('json:', '')\n\n                try:\n                    parse(input)\n                except (JsonPathParserError, JsonPathLexerError) as e:\n                    message = field.gettext('\\'%s\\' is not a valid JSONPath expression. (%s)')\n                    raise ValidationError(message % (input, str(e)))\n                except:\n                    raise ValidationError(\"A system-error occurred when validating your JSONPath expression\")\n\n                # Re #265 - maybe in the future fetch the page and offer a\n                # warning/notice that its possible the rule doesnt yet match anything?\n                if not self.allow_json:\n                    raise ValidationError(\"jq not permitted in this field!\")\n\n            if 'jq:' in line:\n                try:\n                    import jq\n                except ModuleNotFoundError:\n                    # `jq` requires full compilation in windows and so isn't generally available\n                    raise ValidationError(\"jq not support not found\")\n\n                input = line.replace('jq:', '')\n\n                try:\n                    jq.compile(input)\n                except (ValueError) as e:\n                    message = field.gettext('\\'%s\\' is not a valid jq expression. (%s)')\n                    raise ValidationError(message % (input, str(e)))\n                except:\n                    raise ValidationError(\"A system-error occurred when validating your jq expression\")\n\nclass ValidateSimpleURL:\n    \"\"\"Validate that the value can be parsed by urllib.parse.urlparse() and has a scheme/netloc.\"\"\"\n    def __init__(self, message=None):\n        self.message = message or \"Invalid URL.\"\n\n    def __call__(self, form, field):\n        data = (field.data or \"\").strip()\n        if not data:\n            return  # empty is OK — pair with validators.Optional()\n        from urllib.parse import urlparse\n\n        parsed = urlparse(data)\n        if not parsed.scheme or not parsed.netloc:\n            raise ValidationError(self.message)\n\nclass ValidateStartsWithRegex(object):\n    def __init__(self, regex, *, flags=0, message=None, allow_empty=True, split_lines=True):\n        # compile with given flags (we’ll pass re.IGNORECASE below)\n        self.pattern = re.compile(regex, flags) if isinstance(regex, str) else regex\n        self.message = message\n        self.allow_empty = allow_empty\n        self.split_lines = split_lines\n\n    def __call__(self, form, field):\n        data = field.data\n        if not data:\n            return\n\n        # normalize into list of lines\n        if isinstance(data, str) and self.split_lines:\n            lines = data.splitlines()\n        elif isinstance(data, (list, tuple)):\n            lines = data\n        else:\n            lines = [data]\n\n        for line in lines:\n            stripped = line.strip()\n            if not stripped:\n                if self.allow_empty:\n                    continue\n                raise ValidationError(self.message or _l(\"Empty value not allowed.\"))\n            if not self.pattern.match(stripped):\n                raise ValidationError(self.message or _l(\"Invalid value.\"))\n\nclass quickWatchForm(Form):\n    url = fields.URLField(_l('URL'), validators=[validateURL()])\n    tags = StringTagUUID(_l('Group tag'), validators=[validators.Optional()])\n    watch_submit_button = SubmitField(_l('Watch'), render_kw={\"class\": \"pure-button pure-button-primary\"})\n    processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)\n    edit_and_watch_submit_button = SubmitField(_l('Edit > Watch'), render_kw={\"class\": \"pure-button pure-button-primary\"})\n\n\n# Common to a single watch and the global settings\nclass commonSettingsForm(Form):\n    from . import processors\n\n    def __init__(self, formdata=None, obj=None, prefix=\"\", data=None, meta=None, **kwargs):\n        super().__init__(formdata, obj, prefix, data, meta, **kwargs)\n        self.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})\n        self.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})\n        self.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})\n\n    fetch_backend = RadioField(_l('Fetch Method'), choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])\n    notification_body = TextAreaField(_l('Notification Body'), default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])\n    notification_format = SelectField(_l('Notification format'), choices=list(valid_notification_formats.items()))\n    notification_title = StringField(_l('Notification Title'), default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])\n    notification_urls = StringListField(_l('Notification URL List'), validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])\n    processor = RadioField( label=_l(\"Processor - What do you want to achieve?\"), choices=lambda: processors.available_processors(), default=processors.get_default_processor)\n    scheduler_timezone_default = StringField(_l(\"Default timezone for watch check scheduler\"), render_kw={\"list\": \"timezones\"}, validators=[validateTimeZoneName()])\n    webdriver_delay = IntegerField(_l('Wait seconds before extracting text'), validators=[validators.Optional(), validators.NumberRange(min=1, message=_l(\"Should contain one or more seconds\"))])\n\n# Not true anymore but keep the validate_ hook for future use, we convert color tags\n#    def validate_notification_urls(self, field):\n#        \"\"\"Validate that HTML Color format is not used with Telegram\"\"\"\n#        if self.notification_format.data == 'HTML Color' and field.data:\n#            for url in field.data:\n#                if url and ('tgram://' in url or 'discord://' in url or 'discord.com/api/webhooks' in url):\n#                    raise ValidationError('HTML Color format is not supported by Telegram and Discord. Please choose another Notification Format (Plain Text, HTML, or Markdown to HTML).')\n\n\nclass importForm(Form):\n    processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)\n    urls = TextAreaField(_l('URLs'))\n    xlsx_file = FileField(_l('Upload .xlsx file'), validators=[FileAllowed(['xlsx'], _l('Must be .xlsx file!'))])\n    file_mapping = SelectField(_l('File mapping'), [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')})\n\nclass SingleBrowserStep(Form):\n\n    operation = SelectField(_l('Operation'), [validators.Optional()], choices=browser_step_ui_config.keys())\n\n    # maybe better to set some <script>var..\n    selector = StringField(_l('Selector'), [validators.Optional()], render_kw={\"placeholder\": \"CSS or xPath selector\"})\n    optional_value = StringField(_l('value'), [validators.Optional()], render_kw={\"placeholder\": \"Value\"})\n#   @todo move to JS? ajax fetch new field?\n#    remove_button = SubmitField(_l('-'), render_kw={\"type\": \"button\", \"class\": \"pure-button pure-button-primary\", 'title': 'Remove'})\n#    add_button = SubmitField(_l('+'), render_kw={\"type\": \"button\", \"class\": \"pure-button pure-button-primary\", 'title': 'Add new step after'})\n\nclass processor_text_json_diff_form(commonSettingsForm):\n\n    url = fields.URLField('Web Page URL', validators=[validateURL()])\n    tags = StringTagUUID('Group Tag', [validators.Optional()], default='')\n\n    time_between_check = EnhancedFormField(\n        TimeBetweenCheckForm,\n        label=_l('Time Between Check'),\n        conditional_field='time_between_check_use_default',\n        conditional_message=REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT,\n        conditional_test_function=validate_time_between_check_has_values\n    )\n\n    time_schedule_limit = FormField(ScheduleLimitForm)\n\n    time_between_check_use_default = BooleanField(_l('Use global settings for time between check and scheduler.'), default=False)\n\n    include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='')\n\n    subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])\n\n    extract_text = StringListField(_l('Extract text'), [ValidateListRegex()])\n\n    title = StringField(_l('Title'), default='')\n\n    ignore_text = StringListField(_l('Ignore lines containing'), [ValidateListRegex()])\n    headers = StringDictKeyValue('Request headers')\n    body = TextAreaField(_l('Request body'), [validators.Optional()])\n    method = SelectField(_l('Request method'), choices=valid_method, default=default_method)\n    ignore_status_codes = BooleanField(_l('Ignore status codes (process non-2xx status codes as normal)'), default=False)\n    check_unique_lines = BooleanField(_l('Only trigger when unique lines appear in all history'), default=False)\n    remove_duplicate_lines = BooleanField(_l('Remove duplicate lines of text'), default=False)\n    sort_text_alphabetically =  BooleanField(_l('Sort text alphabetically'), default=False)\n    strip_ignored_lines = TernaryNoneBooleanField(_l('Strip ignored lines'), default=None)\n    trim_text_whitespace = BooleanField(_l('Trim whitespace before and after text'), default=False)\n\n    filter_text_added = BooleanField(_l('Added lines'), default=True)\n    filter_text_replaced = BooleanField(_l('Replaced/changed lines'), default=True)\n    filter_text_removed = BooleanField(_l('Removed lines'), default=True)\n\n    trigger_text = StringListField(_l('Keyword triggers - Trigger/wait for text'), [validators.Optional(), ValidateListRegex()])\n    browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10)\n    text_should_not_be_present = StringListField(_l('Block change-detection while text matches'), [validators.Optional(), ValidateListRegex()])\n    webdriver_js_execute_code = TextAreaField(_l('Execute JavaScript before change detection'), render_kw={\"rows\": \"5\"}, validators=[validators.Optional()])\n\n    save_button = SubmitField(_l('Save'), render_kw={\"class\": \"pure-button pure-button-primary\"})\n\n    proxy = RadioField(_l('Proxy'))\n    # filter_failure_notification_send @todo make ternary\n    filter_failure_notification_send = BooleanField(_l('Send a notification when the filter can no longer be found on the page'), default=False)\n    notification_muted = TernaryNoneBooleanField(_l('Notifications'), default=None, yes_text=_l(\"Muted\"), no_text=_l(\"On\"))\n    notification_screenshot = BooleanField(_l('Attach screenshot to notification (where possible)'), default=False)\n\n    conditions_match_logic = RadioField(_l('Match'), choices=[('ALL', _l('Match all of the following')),('ANY', _l('Match any of the following'))], default='ALL')\n    conditions = FieldList(FormField(ConditionFormRow), min_entries=1)  # Add rule logic here\n    use_page_title_in_list = TernaryNoneBooleanField(_l('Use page <title> in list'), default=None)\n\n    history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={\"style\": \"width: 5em;\"}, validators=[validators.Optional(), validators.NumberRange(min=2)])\n\n    def extra_tab_content(self):\n        return None\n\n    def extra_form_content(self):\n        return None\n\n    def validate(self, **kwargs):\n        if not super().validate():\n            return False\n\n        from changedetectionio.jinja2_custom import render as jinja_render\n        result = True\n\n        # Fail form validation when a body is set for a GET\n        if self.method.data == 'GET' and self.body.data:\n            self.body.errors.append(gettext('Body must be empty when Request Method is set to GET'))\n            result = False\n\n        # Attempt to validate jinja2 templates in the URL\n        try:\n            jinja_render(template_str=self.url.data)\n        except ModuleNotFoundError as e:\n            # incase jinja2_time or others is missing\n            logger.error(e)\n            self.url.errors.append(gettext('Invalid template syntax configuration: %(error)s') % {'error': e})\n            result = False\n        except Exception as e:\n            logger.error(e)\n            self.url.errors.append(gettext('Invalid template syntax: %(error)s') % {'error': e})\n            result = False\n\n        # Attempt to validate jinja2 templates in the body\n        if self.body.data and self.body.data.strip():\n            try:\n                jinja_render(template_str=self.body.data)\n            except ModuleNotFoundError as e:\n                # incase jinja2_time or others is missing\n                logger.error(e)\n                self.body.errors.append(gettext('Invalid template syntax configuration: %(error)s') % {'error': e})\n                result = False\n            except Exception as e:\n                logger.error(e)\n                self.body.errors.append(gettext('Invalid template syntax: %(error)s') % {'error': e})\n                result = False\n\n        # Attempt to validate jinja2 templates in the headers\n        if len(self.headers.data) > 0:\n            try:\n                for header, value in self.headers.data.items():\n                    jinja_render(template_str=value)\n            except ModuleNotFoundError as e:\n                # incase jinja2_time or others is missing\n                logger.error(e)\n                self.headers.errors.append(gettext('Invalid template syntax configuration: %(error)s') % {'error': e})\n                result = False\n            except Exception as e:\n                logger.error(e)\n                self.headers.errors.append(gettext('Invalid template syntax in \\\"%(header)s\\\" header: %(error)s') % {'header': header, 'error': e})\n                result = False\n\n        return result\n\n    def __init__(\n            self,\n            formdata=None,\n            obj=None,\n            prefix=\"\",\n            data=None,\n            meta=None,\n            **kwargs,\n    ):\n        super().__init__(formdata, obj, prefix, data, meta, **kwargs)\n        if kwargs and kwargs.get('default_system_settings'):\n            default_tz = kwargs.get('default_system_settings').get('application', {}).get('scheduler_timezone_default')\n            if default_tz:\n                self.time_schedule_limit.form.timezone.render_kw['placeholder'] = default_tz\n\n\n\nclass SingleExtraProxy(Form):\n    # maybe better to set some <script>var..\n    proxy_name = StringField(_l('Name'), [validators.Optional()], render_kw={\"placeholder\": \"Name\"})\n    proxy_url = StringField(_l('Proxy URL'), [\n        validators.Optional(),\n        ValidateStartsWithRegex(\n            regex=r'^(https?|socks5)://',  # ✅ main pattern\n            flags=re.IGNORECASE,  # ✅ makes it case-insensitive\n            message=_l('Proxy URLs must start with http://, https:// or socks5://'),\n        ),\n        ValidateSimpleURL()\n    ], render_kw={\"placeholder\": \"socks5:// or regular proxy http://user:pass@...:3128\", \"size\":50})\n\nclass SingleExtraBrowser(Form):\n    browser_name = StringField(_l('Name'), [validators.Optional()], render_kw={\"placeholder\": \"Name\"})\n    browser_connection_url = StringField(_l('Browser connection URL'), [\n        validators.Optional(),\n        ValidateStartsWithRegex(\n            regex=r'^(wss?|ws)://',\n            flags=re.IGNORECASE,\n            message=_l('Browser URLs must start with wss:// or ws://')\n        ),\n        ValidateSimpleURL()\n    ], render_kw={\"placeholder\": \"wss://brightdata... wss://oxylabs etc\", \"size\":50})\n\nclass DefaultUAInputForm(Form):\n    html_requests = StringField(_l('Plaintext requests'), validators=[validators.Optional()], render_kw={\"placeholder\": \"<default>\"})\n    if os.getenv(\"PLAYWRIGHT_DRIVER_URL\") or os.getenv(\"WEBDRIVER_URL\"):\n        html_webdriver = StringField(_l('Chrome requests'), validators=[validators.Optional()], render_kw={\"placeholder\": \"<default>\"})\n\n# datastore.data['settings']['requests']..\nclass globalSettingsRequestForm(Form):\n    time_between_check = RequiredFormField(TimeBetweenCheckForm, label=_l('Time Between Check'))\n    time_schedule_limit = FormField(ScheduleLimitForm)\n    proxy = RadioField(_l('Default proxy'))\n    jitter_seconds = IntegerField(_l('Random jitter seconds ± check'),\n                                  render_kw={\"style\": \"width: 5em;\"},\n                                  validators=[validators.NumberRange(min=0, message=_l(\"Should contain zero or more seconds\"))])\n    \n    workers = IntegerField(_l('Number of fetch workers'),\n                          render_kw={\"style\": \"width: 5em;\"},\n                          validators=[validators.NumberRange(min=1, max=50,\n                                                             message=_l(\"Should be between 1 and 50\"))])\n\n    timeout = IntegerField(_l('Requests timeout in seconds'),\n                           render_kw={\"style\": \"width: 5em;\"},\n                           validators=[validators.NumberRange(min=1, max=999,\n                                                              message=_l(\"Should be between 1 and 999\"))])\n\n    extra_proxies = FieldList(FormField(SingleExtraProxy), min_entries=5)\n    extra_browsers = FieldList(FormField(SingleExtraBrowser), min_entries=5)\n\n    default_ua = FormField(DefaultUAInputForm, label=_l(\"Default User-Agent overrides\"))\n\n    def validate_extra_proxies(self, extra_validators=None):\n        for e in self.data['extra_proxies']:\n            if e.get('proxy_name') or e.get('proxy_url'):\n                if not e.get('proxy_name','').strip() or not e.get('proxy_url','').strip():\n                    self.extra_proxies.errors.append(gettext('Both a name, and a Proxy URL is required.'))\n                    return False\n\nclass globalSettingsApplicationUIForm(Form):\n    open_diff_in_new_tab = BooleanField(_l(\"Open 'History' page in a new tab\"), default=True, validators=[validators.Optional()])\n    socket_io_enabled = BooleanField(_l('Realtime UI Updates Enabled'), default=True, validators=[validators.Optional()])\n    favicons_enabled = BooleanField(_l('Favicons Enabled'), default=True, validators=[validators.Optional()])\n    use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list')) #BooleanField=True\n\n# datastore.data['settings']['application']..\nclass globalSettingsApplicationForm(commonSettingsForm):\n\n    api_access_token_enabled = BooleanField(_l('API access token security check enabled'), default=True, validators=[validators.Optional()])\n    base_url = StringField(_l('Notification base URL override'),\n                           validators=[validators.Optional()],\n                           render_kw={\"placeholder\": os.getenv('BASE_URL', 'Not set')}\n                           )\n    empty_pages_are_a_change =  BooleanField(_l('Treat empty pages as a change?'), default=False)\n    fetch_backend = RadioField(_l('Fetch Method'), default=\"html_requests\", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])\n    global_ignore_text = StringListField(_l('Ignore Text'), [ValidateListRegex()])\n    global_subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])\n    ignore_whitespace = BooleanField(_l('Ignore whitespace'))\n\n    # Screenshot comparison settings\n    min_change_percentage = FloatField(\n        'Screenshot: Minimum Change Percentage',\n        validators=[\n            validators.Optional(),\n            validators.NumberRange(min=0.0, max=100.0, message=_l('Must be between 0 and 100'))\n        ],\n        default=0.1,\n        render_kw={\"placeholder\": \"0.1\", \"style\": \"width: 8em;\"}\n    )\n\n    password = SaltyPasswordField(_l('Password'))\n    pager_size = IntegerField(_l('Pager size'),\n                              render_kw={\"style\": \"width: 5em;\"},\n                              validators=[validators.NumberRange(min=0,\n                                                                 message=_l(\"Should be atleast zero (disabled)\"))])\n\n    rss_content_format = SelectField(_l('RSS Content format'), choices=list(RSS_FORMAT_TYPES.items()))\n    rss_template_type = SelectField(_l('RSS <description> body built from'), choices=list(RSS_TEMPLATE_TYPE_OPTIONS.items()))\n    rss_template_override = TextAreaField(_l('RSS \"System default\" template override'), render_kw={\"rows\": \"5\", \"placeholder\": RSS_TEMPLATE_HTML_DEFAULT}, validators=[validators.Optional(), ValidateJinja2Template()])\n\n    removepassword_button = SubmitField(_l('Remove password'), render_kw={\"class\": \"pure-button pure-button-primary\"})\n    render_anchor_tag_content = BooleanField(_l('Render anchor tag content'), default=False)\n    shared_diff_access = BooleanField(_l('Allow anonymous access to watch history page when password is enabled'), default=False, validators=[validators.Optional()])\n    strip_ignored_lines = BooleanField(_l('Strip ignored lines'))\n    rss_hide_muted_watches = BooleanField(_l('Hide muted watches from RSS feed'), default=True,\n                                      validators=[validators.Optional()])\n\n    rss_reader_mode = BooleanField(_l('Enable RSS reader mode '), default=False, validators=[validators.Optional()])\n    rss_diff_length = IntegerField(label=_l('Number of changes to show in watch RSS feed'),\n                                   render_kw={\"style\": \"width: 5em;\"},\n                                   validators=[validators.NumberRange(min=0, message=_l(\"Should contain zero or more attempts\"))])\n\n    filter_failure_notification_threshold_attempts = IntegerField(_l('Number of times the filter can be missing before sending a notification'),\n                                                                  render_kw={\"style\": \"width: 5em;\"},\n                                                                  validators=[validators.NumberRange(min=0,\n                                                                                                     message=_l(\"Should contain zero or more attempts\"))])\n\n    history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={\"style\": \"width: 5em;\"}, validators=[validators.Optional(), validators.NumberRange(min=2)])\n    ui = FormField(globalSettingsApplicationUIForm)\n\n\nclass globalSettingsForm(Form):\n    # Define these as FormFields/\"sub forms\", this way it matches the JSON storage\n    # datastore.data['settings']['application']..\n    # datastore.data['settings']['requests']..\n    def __init__(self, formdata=None, obj=None, prefix=\"\", data=None, meta=None, **kwargs):\n        super().__init__(formdata, obj, prefix, data, meta, **kwargs)\n        self.application.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})\n        self.application.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})\n        self.application.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})\n\n    requests = FormField(globalSettingsRequestForm)\n    application = FormField(globalSettingsApplicationForm)\n    save_button = SubmitField(_l('Save'), render_kw={\"class\": \"pure-button pure-button-primary\"})\n\n\nclass extractDataForm(Form):\n    extract_regex = StringField(_l('RegEx to extract'), validators=[validators.DataRequired(), ValidateSinglePythonRegexString()])\n    extract_submit_button = SubmitField(_l('Extract as CSV'), render_kw={\"class\": \"pure-button pure-button-primary\"})\n"
  },
  {
    "path": "changedetectionio/gc_cleanup.py",
    "content": "#!/usr/bin/env python3\n\nimport ctypes\nimport gc\nimport re\nimport psutil\nimport sys\nimport threading\nimport importlib\nfrom loguru import logger\n\ndef memory_cleanup(app=None):\n    \"\"\"\n    Perform comprehensive memory cleanup operations and log memory usage\n    at each step with nicely formatted numbers.\n    \n    Args:\n        app: Optional Flask app instance for clearing Flask-specific caches\n        \n    Returns:\n        str: Status message\n    \"\"\"\n    # Get current process\n    process = psutil.Process()\n    \n    # Log initial memory usage with nicely formatted numbers\n    current_memory = process.memory_info().rss / 1024 / 1024\n    logger.debug(f\"Memory cleanup started - Current memory usage: {current_memory:,.2f} MB\")\n\n    # 1. Standard garbage collection - force full collection on all generations\n    gc.collect(0)  # Collect youngest generation\n    gc.collect(1)  # Collect middle generation\n    gc.collect(2)  # Collect oldest generation\n\n    # Run full collection again to ensure maximum cleanup\n    gc.collect()\n    current_memory = process.memory_info().rss / 1024 / 1024\n    logger.debug(f\"After full gc.collect() - Memory usage: {current_memory:,.2f} MB\")\n    \n\n    # 3. Call libc's malloc_trim to release memory back to the OS\n    libc = ctypes.CDLL(\"libc.so.6\")\n    libc.malloc_trim(0)\n    current_memory = process.memory_info().rss / 1024 / 1024\n    logger.debug(f\"After malloc_trim(0) - Memory usage: {current_memory:,.2f} MB\")\n    \n    # 4. Clear Python's regex cache\n    re.purge()\n    current_memory = process.memory_info().rss / 1024 / 1024\n    logger.debug(f\"After re.purge() - Memory usage: {current_memory:,.2f} MB\")\n\n    # 5. Reset thread-local storage\n    # Create a new thread local object to encourage cleanup of old ones\n    threading.local()\n    current_memory = process.memory_info().rss / 1024 / 1024\n    logger.debug(f\"After threading.local() - Memory usage: {current_memory:,.2f} MB\")\n\n    # 6. Clear sys.intern cache if Python version supports it\n    try:\n        sys.intern.clear()\n        current_memory = process.memory_info().rss / 1024 / 1024\n        logger.debug(f\"After sys.intern.clear() - Memory usage: {current_memory:,.2f} MB\")\n    except (AttributeError, TypeError):\n        logger.debug(\"sys.intern.clear() not supported in this Python version\")\n    \n    # 7. Clear XML/lxml caches if available\n    try:\n        # Check if lxml.etree is in use\n        lxml_etree = sys.modules.get('lxml.etree')\n        if lxml_etree:\n            # Clear module-level caches\n            if hasattr(lxml_etree, 'clear_error_log'):\n                lxml_etree.clear_error_log()\n            \n            # Check for _ErrorLog and _RotatingErrorLog objects and clear them\n            for obj in gc.get_objects():\n                if hasattr(obj, '__class__') and hasattr(obj.__class__, '__name__'):\n                    class_name = obj.__class__.__name__\n                    if class_name in ('_ErrorLog', '_RotatingErrorLog', '_DomainErrorLog') and hasattr(obj, 'clear'):\n                        try:\n                            obj.clear()\n                        except (AttributeError, TypeError):\n                            pass\n                    \n                    # Clear Element objects which can hold references to documents\n                    elif class_name in ('_Element', 'ElementBase') and hasattr(obj, 'clear'):\n                        try:\n                            obj.clear()\n                        except (AttributeError, TypeError):\n                            pass\n            \n            current_memory = process.memory_info().rss / 1024 / 1024\n            logger.debug(f\"After lxml.etree cleanup - Memory usage: {current_memory:,.2f} MB\")\n\n        # Check if lxml.html is in use\n        lxml_html = sys.modules.get('lxml.html')\n        if lxml_html:\n            # Clear HTML-specific element types\n            for obj in gc.get_objects():\n                if hasattr(obj, '__class__') and hasattr(obj.__class__, '__name__'):\n                    class_name = obj.__class__.__name__\n                    if class_name in ('HtmlElement', 'FormElement', 'InputElement',\n                                    'SelectElement', 'TextareaElement', 'CheckboxGroup',\n                                    'RadioGroup', 'MultipleSelectOptions', 'FieldsDict') and hasattr(obj, 'clear'):\n                        try:\n                            obj.clear()\n                        except (AttributeError, TypeError):\n                            pass\n\n            current_memory = process.memory_info().rss / 1024 / 1024\n            logger.debug(f\"After lxml.html cleanup - Memory usage: {current_memory:,.2f} MB\")\n    except (ImportError, AttributeError):\n        logger.debug(\"lxml cleanup not applicable\")\n    \n    # 8. Clear JSON parser caches if applicable\n    try:\n        # Check if json module is being used and try to clear its cache\n        json_module = sys.modules.get('json')\n        if json_module and hasattr(json_module, '_default_encoder'):\n            json_module._default_encoder.markers.clear()\n            current_memory = process.memory_info().rss / 1024 / 1024\n            logger.debug(f\"After JSON parser cleanup - Memory usage: {current_memory:,.2f} MB\")\n    except (AttributeError, KeyError):\n        logger.debug(\"JSON cleanup not applicable\")\n    \n    # 9. Force Python's memory allocator to release unused memory\n    try:\n        if hasattr(sys, 'pypy_version_info'):\n            # PyPy has different memory management\n            gc.collect()\n        else:\n            # CPython - try to release unused memory\n            ctypes.pythonapi.PyGC_Collect()\n            current_memory = process.memory_info().rss / 1024 / 1024\n            logger.debug(f\"After PyGC_Collect - Memory usage: {current_memory:,.2f} MB\")\n    except (AttributeError, TypeError):\n        logger.debug(\"PyGC_Collect not supported\")\n    \n    # 10. Clear Flask-specific caches if applicable\n    if app:\n        try:\n            # Clear Flask caches if they exist\n            for key in list(app.config.get('_cache', {}).keys()):\n                app.config['_cache'].pop(key, None)\n            \n            # Clear Jinja2 template cache if available\n            if hasattr(app, 'jinja_env') and hasattr(app.jinja_env, 'cache'):\n                app.jinja_env.cache.clear()\n            \n            current_memory = process.memory_info().rss / 1024 / 1024\n            logger.debug(f\"After Flask cache clear - Memory usage: {current_memory:,.2f} MB\")\n        except (AttributeError, KeyError):\n            logger.debug(\"No Flask cache to clear\")\n    \n    # Final garbage collection pass\n    gc.collect()\n    libc.malloc_trim(0)\n    \n    # Log final memory usage\n    final_memory = process.memory_info().rss / 1024 / 1024\n    logger.info(f\"Memory cleanup completed - Final memory usage: {final_memory:,.2f} MB\")\n    return \"cleaned\""
  },
  {
    "path": "changedetectionio/html_tools.py",
    "content": "from functools import lru_cache\n\nfrom loguru import logger\nfrom typing import List\nimport html\nimport json\nimport re\n\n# HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis\nTEXT_FILTER_LIST_LINE_SUFFIX = \"<br>\"\nTRANSLATE_WHITESPACE_TABLE = str.maketrans('', '', '\\r\\n\\t ')\nPERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$'\n\nTITLE_RE = re.compile(r\"<title[^>]*>(.*?)</title>\", re.I | re.S)\nMETA_CS  = re.compile(r'<meta[^>]+charset=[\"\\']?\\s*([a-z0-9_\\-:+.]+)', re.I)\nMETA_CT  = re.compile(r'<meta[^>]+http-equiv=[\"\\']?content-type[\"\\']?[^>]*content=[\"\\'][^>]*charset=([a-z0-9_\\-:+.]+)', re.I)\n\n# 'price' , 'lowPrice', 'highPrice' are usually under here\n# All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here\nLD_JSON_PRODUCT_OFFER_SELECTORS = [\"json:$..offers\", \"json:$..Offers\"]\n\nclass JSONNotFound(ValueError):\n    def __init__(self, msg):\n        ValueError.__init__(self, msg)\n\n\n_DEFAULT_UNSAFE_XPATH3_FUNCTIONS = [\n    'unparsed-text',\n    'unparsed-text-lines',\n    'unparsed-text-available',\n    'doc',\n    'doc-available',\n    'environment-variable',\n    'available-environment-variables',\n]\n\n\ndef _build_safe_xpath3_parser():\n    \"\"\"Return an XPath3Parser subclass with filesystem/environment access functions removed.\n\n    XPath 3.0 includes functions that can read arbitrary files or environment variables:\n      - unparsed-text / unparsed-text-lines / unparsed-text-available  (file read)\n      - doc / doc-available                                             (XML fetch from URI)\n      - environment-variable / available-environment-variables         (env var leakage)\n\n    Subclassing gives us an independent symbol_table copy (not shared with the parent class),\n    so removing entries here does not affect XPath3Parser itself.\n\n    Override the blocked list via the XPATH_BLOCKED_FUNCTIONS environment variable\n    (comma-separated, e.g. \"unparsed-text,doc,environment-variable\").\n    \"\"\"\n    import os\n    from elementpath.xpath3 import XPath3Parser\n\n    class SafeXPath3Parser(XPath3Parser):\n        pass\n\n    env_override = os.getenv('XPATH_BLOCKED_FUNCTIONS')\n    if env_override is not None:\n        blocked = [f.strip() for f in env_override.split(',') if f.strip()]\n    else:\n        blocked = _DEFAULT_UNSAFE_XPATH3_FUNCTIONS\n\n    for _fn in blocked:\n        SafeXPath3Parser.symbol_table.pop(_fn, None)\n\n    return SafeXPath3Parser\n\n\n# Module-level singleton — built once, reused everywhere.\nSafeXPath3Parser = _build_safe_xpath3_parser()\n\n# Doesn't look like python supports forward slash auto enclosure in re.findall\n# So convert it to inline flag \"(?i)foobar\" type configuration\n@lru_cache(maxsize=100)\ndef perl_style_slash_enclosed_regex_to_options(regex):\n\n    res = re.search(PERL_STYLE_REGEX, regex, re.IGNORECASE)\n\n    if res:\n        flags = res.group(2) if res.group(2) else 'i'\n        regex = f\"(?{flags}){res.group(1)}\"\n    else:\n        # Fall back to just ignorecase as an option\n        regex = f\"(?i){regex}\"\n\n    return regex\n\n# Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches\ndef include_filters(include_filters, html_content, append_pretty_line_formatting=False):\n    from bs4 import BeautifulSoup\n    soup = BeautifulSoup(html_content, \"html.parser\")\n    html_block = \"\"\n    r = soup.select(include_filters, separator=\"\")\n\n    for element in r:\n        # When there's more than 1 match, then add the suffix to separate each line\n        # And where the matched result doesn't include something that will cause Inscriptis to add a newline\n        # (This way each 'match' reliably has a new-line in the diff)\n        # Divs are converted to 4 whitespaces by inscriptis\n        if append_pretty_line_formatting and len(html_block) and not element.name in (['br', 'hr', 'div', 'p']):\n            html_block += TEXT_FILTER_LIST_LINE_SUFFIX\n\n        html_block += str(element)\n\n    return html_block\n\ndef subtractive_css_selector(css_selector, content):\n    from bs4 import BeautifulSoup\n    soup = BeautifulSoup(content, \"html.parser\")\n\n    # So that the elements dont shift their index, build a list of elements here which will be pointers to their place in the DOM\n    elements_to_remove = soup.select(css_selector)\n\n    if not elements_to_remove:\n        # Better to return the original that rebuild with BeautifulSoup\n        return content\n\n    # Then, remove them in a separate loop\n    for item in elements_to_remove:\n        item.decompose()\n\n    return str(soup)\n\ndef subtractive_xpath_selector(selectors: List[str], html_content: str) -> str:\n    from lxml import etree\n    # Parse the HTML content using lxml\n    html_tree = etree.HTML(html_content)\n\n    # First, collect all elements to remove\n    elements_to_remove = []\n\n    # Iterate over the list of XPath selectors\n    for selector in selectors:\n        # Collect elements for each selector\n        elements_to_remove.extend(html_tree.xpath(selector))\n\n    # If no elements were found, return the original HTML content\n    if not elements_to_remove:\n        return html_content\n\n    # Then, remove them in a separate loop\n    for element in elements_to_remove:\n        if element.getparent() is not None:  # Ensure the element has a parent before removing\n            element.getparent().remove(element)\n\n    # Convert the modified HTML tree back to a string\n    modified_html = etree.tostring(html_tree, method=\"html\").decode(\"utf-8\")\n    return modified_html\n\n\ndef element_removal(selectors: List[str], html_content):\n    \"\"\"Removes elements that match a list of CSS or XPath selectors.\"\"\"\n    modified_html = html_content\n    css_selectors = []\n    xpath_selectors = []\n\n    for selector in selectors:\n        if selector.strip().startswith(('xpath:', 'xpath1:', '//')):\n            # Handle XPath selectors separately\n            xpath_selector = selector.removeprefix('xpath:').removeprefix('xpath1:')\n            xpath_selectors.append(xpath_selector)\n        else:\n            # Collect CSS selectors as one \"hit\", see comment in subtractive_css_selector\n            css_selectors.append(selector.strip().strip(\",\"))\n\n    if xpath_selectors:\n        modified_html = subtractive_xpath_selector(xpath_selectors, modified_html)\n\n    if css_selectors:\n        # Remove duplicates, then combine all CSS selectors into one string, separated by commas\n        # This stops the elements index shifting\n        unique_selectors = list(set(css_selectors))  # Ensure uniqueness\n        combined_css_selector = \" , \".join(unique_selectors)\n        modified_html = subtractive_css_selector(combined_css_selector, modified_html)\n\n\n    return modified_html\n\ndef elementpath_tostring(obj):\n    \"\"\"\n    change elementpath.select results to string type\n    # The MIT License (MIT), Copyright (c), 2018-2021, SISSA (Scuola Internazionale Superiore di Studi Avanzati)\n    # https://github.com/sissaschool/elementpath/blob/dfcc2fd3d6011b16e02bf30459a7924f547b47d0/elementpath/xpath_tokens.py#L1038\n    \"\"\"\n\n    import elementpath\n    from decimal import Decimal\n    import math\n\n    if obj is None:\n        return ''\n    # https://elementpath.readthedocs.io/en/latest/xpath_api.html#elementpath.select\n    elif isinstance(obj, elementpath.XPathNode):\n        return obj.string_value\n    elif isinstance(obj, bool):\n        return 'true' if obj else 'false'\n    elif isinstance(obj, Decimal):\n        value = format(obj, 'f')\n        if '.' in value:\n            return value.rstrip('0').rstrip('.')\n        return value\n\n    elif isinstance(obj, float):\n        if math.isnan(obj):\n            return 'NaN'\n        elif math.isinf(obj):\n            return str(obj).upper()\n\n        value = str(obj)\n        if '.' in value:\n            value = value.rstrip('0').rstrip('.')\n        if '+' in value:\n            value = value.replace('+', '')\n        if 'e' in value:\n            return value.upper()\n        return value\n\n    return str(obj)\n\n# Return str Utf-8 of matched rules\ndef xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False, is_xml=False):\n    \"\"\"\n\n    :param xpath_filter:\n    :param html_content:\n    :param append_pretty_line_formatting:\n    :param is_xml: set to true if is XML or is RSS (RSS is XML)\n    :return:\n    \"\"\"\n    from lxml import etree, html\n    import elementpath\n\n    parser = etree.HTMLParser()\n    tree = None\n    try:\n        if is_xml:\n            # So that we can keep CDATA for cdata_in_document_to_text() to process\n            parser = etree.XMLParser(strip_cdata=False)\n            # For XML/RSS content, use etree.fromstring to properly handle XML declarations\n            tree = etree.fromstring(html_content.encode('utf-8') if isinstance(html_content, str) else html_content, parser=parser)\n        else:\n            tree = html.fromstring(html_content, parser=parser)\n        html_block = \"\"\n\n        # Build namespace map for XPath queries\n        namespaces = {'re': 'http://exslt.org/regular-expressions'}\n\n        # Handle default namespace in documents (common in RSS/Atom feeds, but can occur in any XML)\n        # XPath spec: unprefixed element names have no namespace, not the default namespace\n        # Solution: Register the default namespace with empty string prefix in elementpath\n        # This is primarily for RSS/Atom feeds but works for any XML with default namespace\n        if hasattr(tree, 'nsmap') and tree.nsmap and None in tree.nsmap:\n            # Register the default namespace with empty string prefix for elementpath\n            # This allows //title to match elements in the default namespace\n            namespaces[''] = tree.nsmap[None]\n\n        r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=SafeXPath3Parser)\n        #@note: //title/text() now works with default namespaces (fixed by registering '' prefix)\n        #@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)\n\n        if type(r) != list:\n            r = [r]\n\n        for element in r:\n            # When there's more than 1 match, then add the suffix to separate each line\n            # And where the matched result doesn't include something that will cause Inscriptis to add a newline\n            # (This way each 'match' reliably has a new-line in the diff)\n            # Divs are converted to 4 whitespaces by inscriptis\n            if append_pretty_line_formatting and len(html_block) and (not hasattr( element, 'tag' ) or not element.tag in (['br', 'hr', 'div', 'p'])):\n                html_block += TEXT_FILTER_LIST_LINE_SUFFIX\n\n            if type(element) == str:\n                html_block += element\n            elif issubclass(type(element), etree._Element) or issubclass(type(element), etree._ElementTree):\n                # Use 'xml' method for RSS/XML content, 'html' for HTML content\n                # parser will be XMLParser if we detected XML content\n                method = 'xml' if (is_xml or isinstance(parser, etree.XMLParser)) else 'html'\n                html_block += etree.tostring(element, pretty_print=True, method=method, encoding='unicode')\n            else:\n                html_block += elementpath_tostring(element)\n\n        # Drop element references before the finally block so tree.clear() can release\n        # the libxml2 document immediately (elements pin the C-level doc via refcount).\n        del r\n        return html_block\n    finally:\n        # Explicitly clear the tree to free memory\n        # lxml trees can hold significant memory, especially with large documents\n        if tree is not None:\n            tree.clear()\n\n# Return str Utf-8 of matched rules\n# 'xpath1:'\ndef xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=False, is_xml=False):\n    from lxml import etree, html\n\n    parser = None\n    tree = None\n    try:\n        if is_xml:\n            # So that we can keep CDATA for cdata_in_document_to_text() to process\n            parser = etree.XMLParser(strip_cdata=False)\n            # For XML/RSS content, use etree.fromstring to properly handle XML declarations\n            tree = etree.fromstring(html_content.encode('utf-8') if isinstance(html_content, str) else html_content, parser=parser)\n        else:\n            tree = html.fromstring(html_content, parser=parser)\n        html_block = \"\"\n\n        # Build namespace map for XPath queries\n        namespaces = {'re': 'http://exslt.org/regular-expressions'}\n\n        # NOTE: lxml's native xpath() does NOT support empty string prefix for default namespace\n        # For documents with default namespace (RSS/Atom feeds), users must use:\n        #   - local-name(): //*[local-name()='title']/text()\n        #   - Or use xpath_filter (not xpath1_filter) which supports default namespaces\n        # XPath spec: unprefixed element names have no namespace, not the default namespace\n\n        r = tree.xpath(xpath_filter.strip(), namespaces=namespaces)\n        #@note: xpath1 (lxml) does NOT automatically handle default namespaces\n        #@note: Use //*[local-name()='element'] or switch to xpath_filter for default namespace support\n        #@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)\n\n        for element in r:\n            # When there's more than 1 match, then add the suffix to separate each line\n            # And where the matched result doesn't include something that will cause Inscriptis to add a newline\n            # (This way each 'match' reliably has a new-line in the diff)\n            # Divs are converted to 4 whitespaces by inscriptis\n            if append_pretty_line_formatting and len(html_block) and (not hasattr(element, 'tag') or not element.tag in (['br', 'hr', 'div', 'p'])):\n                html_block += TEXT_FILTER_LIST_LINE_SUFFIX\n\n            # Some kind of text, UTF-8 or other\n            if isinstance(element, (str, bytes)):\n                html_block += element\n            else:\n                # Return the HTML/XML which will get parsed as text\n                # Use 'xml' method for RSS/XML content, 'html' for HTML content\n                # parser will be XMLParser if we detected XML content\n                method = 'xml' if (is_xml or isinstance(parser, etree.XMLParser)) else 'html'\n                html_block += etree.tostring(element, pretty_print=True, method=method, encoding='unicode')\n\n        return html_block\n    finally:\n        # Explicitly clear the tree to free memory\n        # lxml trees can hold significant memory, especially with large documents\n        if tree is not None:\n            tree.clear()\n\n# Extract/find element\ndef extract_element(find='title', html_content=''):\n    from bs4 import BeautifulSoup\n\n    #Re #106, be sure to handle when its not found\n    element_text = None\n\n    soup = BeautifulSoup(html_content, 'html.parser')\n    result = soup.find(find)\n    if result and result.string:\n        element_text = result.string.strip()\n\n    return element_text\n\n#\ndef _parse_json(json_data, json_filter):\n    from jsonpath_ng.ext import parse\n\n    if json_filter.startswith(\"json:\"):\n        jsonpath_expression = parse(json_filter.replace('json:', ''))\n        match = jsonpath_expression.find(json_data)\n        return _get_stripped_text_from_json_match(match)\n\n    if json_filter.startswith(\"jq:\") or json_filter.startswith(\"jqraw:\"):\n\n        try:\n            import jq\n        except ModuleNotFoundError:\n            # `jq` requires full compilation in windows and so isn't generally available\n            raise Exception(\"jq not support not found\")\n\n        if json_filter.startswith(\"jq:\"):\n            jq_expression = jq.compile(json_filter.removeprefix(\"jq:\"))\n            match = jq_expression.input(json_data).all()\n            return _get_stripped_text_from_json_match(match)\n\n        if json_filter.startswith(\"jqraw:\"):\n            jq_expression = jq.compile(json_filter.removeprefix(\"jqraw:\"))\n            match = jq_expression.input(json_data).all()\n            return '\\n'.join(str(item) for item in match)\n\ndef _get_stripped_text_from_json_match(match):\n    s = []\n    # More than one result, we will return it as a JSON list.\n    if len(match) > 1:\n        for i in match:\n            s.append(i.value if hasattr(i, 'value') else i)\n\n    # Single value, use just the value, as it could be later used in a token in notifications.\n    if len(match) == 1:\n        s = match[0].value if hasattr(match[0], 'value') else match[0]\n\n    # Re #257 - Better handling where it does not exist, in the case the original 's' value was False..\n    if not match:\n        # Re 265 - Just return an empty string when filter not found\n        return ''\n\n    # Ticket #462 - allow the original encoding through, usually it's UTF-8 or similar\n    stripped_text_from_html = json.dumps(s, indent=4, ensure_ascii=False)\n\n    return stripped_text_from_html\n\ndef extract_json_blob_from_html(content, ensure_is_ldjson_info_type, json_filter):\n    from bs4 import BeautifulSoup\n    stripped_text_from_html = ''\n\n    # Foreach <script json></script> blob.. just return the first that matches json_filter\n    # As a last resort, try to parse the whole <body>\n    soup = BeautifulSoup(content, 'html.parser')\n\n    if ensure_is_ldjson_info_type:\n        bs_result = soup.find_all('script', {\"type\": \"application/ld+json\"})\n    else:\n        bs_result = soup.find_all('script')\n    bs_result += soup.find_all('body')\n\n    bs_jsons = []\n\n    for result in bs_result:\n        # result.text is how bs4 magically strips JSON from the body\n        content_start = result.text.lstrip(\"\\ufeff\").strip()[:100] if result.text else ''\n        # Skip empty tags, and things that dont even look like JSON\n        if not result.text or not (content_start[0] == '{' or content_start[0] == '['):\n            continue\n        try:\n            json_data = json.loads(result.text)\n            bs_jsons.append(json_data)\n        except json.JSONDecodeError:\n            # Skip objects which cannot be parsed\n            continue\n\n    if not bs_jsons:\n        raise JSONNotFound(\"No parsable JSON found in this document\")\n\n    for json_data in bs_jsons:\n        stripped_text_from_html = _parse_json(json_data, json_filter)\n\n        if ensure_is_ldjson_info_type:\n            # Could sometimes be list, string or something else random\n            if isinstance(json_data, dict):\n                # If it has LD JSON 'key' @type, and @type is 'product', and something was found for the search\n                # (Some sites have multiple of the same ld+json @type='product', but some have the review part, some have the 'price' part)\n                # @type could also be a list although non-standard (\"@type\": [\"Product\", \"SubType\"],)\n                # LD_JSON auto-extract also requires some content PLUS the ldjson to be present\n                # 1833 - could be either str or dict, should not be anything else\n\n                t = json_data.get('@type')\n                if t and stripped_text_from_html:\n\n                    if isinstance(t, str) and t.lower() == ensure_is_ldjson_info_type.lower():\n                        break\n                    # The non-standard part, some have a list\n                    elif isinstance(t, list):\n                        if ensure_is_ldjson_info_type.lower() in [x.lower().strip() for x in t]:\n                            break\n\n        elif stripped_text_from_html:\n            break\n\n    return stripped_text_from_html\n\n# content - json\n# json_filter - ie json:$..price\n# ensure_is_ldjson_info_type - str \"product\", optional, \"@type == product\" (I dont know how to do that as a json selector)\ndef extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None):\n\n    stripped_text_from_html = False\n# https://github.com/dgtlmoon/changedetection.io/pull/2041#issuecomment-1848397161w\n    # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags\n\n    # Looks like clean JSON, dont bother extracting from HTML\n\n    content_start = content.lstrip(\"\\ufeff\").strip()[:100]\n\n    if content_start[0] == '{' or content_start[0] == '[':\n        try:\n            # .lstrip(\"\\ufeff\") strings ByteOrderMark from UTF8 and still lets the UTF work\n            stripped_text_from_html = _parse_json(json.loads(content.lstrip(\"\\ufeff\")), json_filter)\n        except json.JSONDecodeError as e:\n            logger.warning(f\"Error processing JSON {content[:20]}...{str(e)})\")\n    else:\n        # Check for JSONP wrapper: someCallback({...}) or some.namespace({...})\n        # Server may claim application/json but actually return JSONP\n        jsonp_match = re.match(r'^\\w[\\w.]*\\s*\\((.+)\\)\\s*;?\\s*$', content.lstrip(\"\\ufeff\").strip(), re.DOTALL)\n        if jsonp_match:\n            try:\n                inner = jsonp_match.group(1).strip()\n                logger.warning(f\"Content looks like JSONP, attempting to extract inner JSON for filter '{json_filter}'\")\n                stripped_text_from_html = _parse_json(json.loads(inner), json_filter)\n            except json.JSONDecodeError as e:\n                logger.warning(f\"Error processing JSONP inner content {content[:20]}...{str(e)})\")\n\n        if not stripped_text_from_html:\n            # Probably something else, go fish inside for it\n            try:\n                stripped_text_from_html = extract_json_blob_from_html(content=content,\n                                                                      ensure_is_ldjson_info_type=ensure_is_ldjson_info_type,\n                                                                      json_filter=json_filter)\n            except json.JSONDecodeError as e:\n                logger.warning(f\"Error processing JSON while extracting JSON from HTML blob {content[:20]}...{str(e)})\")\n\n    if not stripped_text_from_html:\n        # Re 265 - Just return an empty string when filter not found\n        return ''\n\n    return stripped_text_from_html\n\n# Mode     - \"content\" return the content without the matches (default)\n#          - \"line numbers\" return a list of line numbers that match (int list)\n#\n# wordlist - list of regex's (str) or words (str)\n# Preserves all linefeeds and other whitespacing, its not the job of this to remove that\ndef strip_ignore_text(content, wordlist, mode=\"content\"):\n    ignore_text = []\n    ignore_regex = []\n    ignore_regex_multiline = []\n    ignored_lines = []\n\n    if not content:\n        return ''\n\n    for k in wordlist:\n        # Skip empty strings to avoid matching everything\n        if not k or not k.strip():\n            continue\n        # Is it a regex?\n        res = re.search(PERL_STYLE_REGEX, k, re.IGNORECASE)\n        if res:\n            res = re.compile(perl_style_slash_enclosed_regex_to_options(k))\n            if res.flags & re.DOTALL or res.flags & re.MULTILINE:\n                ignore_regex_multiline.append(res)\n            else:\n                ignore_regex.append(res)\n        else:\n            ignore_text.append(k.strip())\n\n    for r in ignore_regex_multiline:\n        for match in r.finditer(content):\n            content_lines = content[:match.end()].splitlines(keepends=True)\n            match_lines = content[match.start():match.end()].splitlines(keepends=True)\n\n            end_line = len(content_lines)\n            start_line = end_line - len(match_lines)\n\n            if end_line - start_line <= 1:\n                # Match is empty or in the middle of the line\n                ignored_lines.append(start_line)\n            else:\n                for i in range(start_line, end_line):\n                    ignored_lines.append(i)\n\n    line_index = 0\n    lines = content.splitlines(keepends=True)\n    for line in lines:\n        # Always ignore blank lines in this mode. (when this function gets called)\n        got_match = False\n        for l in ignore_text:\n            if l.lower() in line.lower():\n                got_match = True\n\n        if not got_match:\n            for r in ignore_regex:\n                if r.search(line):\n                    got_match = True\n\n        if got_match:\n            ignored_lines.append(line_index)\n\n        line_index += 1\n\n    ignored_lines = set([i for i in ignored_lines if i >= 0 and i < len(lines)])\n\n    # Used for finding out what to highlight\n    if mode == \"line numbers\":\n        return [i + 1 for i in ignored_lines]\n\n    output_lines = set(range(len(lines))) - ignored_lines\n    return ''.join([lines[i] for i in output_lines])\n\ndef cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str:\n    from xml.sax.saxutils import escape as xml_escape\n    pattern = '<!\\[CDATA\\[(\\s*(?:.(?<!\\]\\]>)\\s*)*)\\]\\]>'\n    def repl(m):\n        text = m.group(1)\n        return xml_escape(html_to_text(html_content=text)).strip()\n\n    return re.sub(pattern, repl, html_content)\n\n\n# NOTE!! ANYTHING LIBXML, HTML5LIB ETC WILL CAUSE SOME SMALL MEMORY LEAK IN THE LOCAL \"LIB\" IMPLEMENTATION OUTSIDE PYTHON\n\n\ndef html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False, timeout=10) -> str:\n    \"\"\"\n    Convert HTML content to plain text using inscriptis.\n\n    Thread-Safety: This function uses inscriptis.get_text() which internally calls\n    lxml.html.fromstring() with the default parser. Testing with 50 concurrent threads\n    confirms this approach is thread-safe and produces deterministic output.\n\n    Alternative Approach Rejected: An explicit HTMLParser instance (thread-local or fresh)\n    would also be thread-safe, but was found to break change detection logic in subtle ways\n    (test_check_basic_change_detection_functionality). The default parser provides correct\n    and reliable behavior.\n    \"\"\"\n    from inscriptis import get_text\n    from inscriptis.model.config import ParserConfig\n\n    if render_anchor_tag_content:\n        parser_config = ParserConfig(\n            annotation_rules={\"a\": [\"hyperlink\"]},\n            display_links=True\n        )\n    else:\n        parser_config = None\n    if is_rss:\n        html_content = re.sub(r'<title([\\s>])', r'<h1\\1', html_content)\n        html_content = re.sub(r'</title>', r'</h1>', html_content)\n    else:\n        # Use BS4 html.parser to strip bloat — SPA's often dump 10MB+ of CSS/JS into <head>,\n        # causing inscriptis to silently give up. Regex-based stripping is unsafe because tags\n        # can appear inside JSON data attributes with JS-escaped closing tags (e.g. <\\/script>),\n        # causing the regex to scan past the intended close and eat real page content.\n        from bs4 import BeautifulSoup\n        soup = BeautifulSoup(html_content, 'html.parser')\n        # Strip tags that inscriptis cannot render as meaningful text and which can be very large.\n        # svg/math: produce path-data/MathML garbage; canvas/iframe/template: no inscriptis handlers.\n        # video/audio/picture are kept — they may contain meaningful fallback text or captions.\n        for tag in soup.find_all(['head', 'script', 'style', 'noscript', 'svg',\n                                  'math', 'canvas', 'iframe', 'template']):\n            tag.decompose()\n\n        # SPAs often use <body style=\"display:none\"> to hide content until JS loads.\n        # inscriptis respects CSS display rules, so strip hiding styles from the body tag.\n        body_tag = soup.find('body')\n        if body_tag and body_tag.get('style'):\n            style = body_tag['style']\n            if re.search(r'\\b(?:display\\s*:\\s*none|visibility\\s*:\\s*hidden)\\b', style, re.IGNORECASE):\n                logger.debug(f\"html_to_text: Removing hiding styles from body tag (found: '{style}')\")\n                del body_tag['style']\n\n        html_content = str(soup)\n\n    text_content = get_text(html_content, config=parser_config)\n    return text_content\n\n# Does LD+JSON exist with a @type=='product' and a .price set anywhere?\ndef has_ldjson_product_info(content):\n    try:\n        # Better than .lower() which can use a lot of ram\n        if (re.search(r'application/ld\\+json', content, re.IGNORECASE) and\n            re.search(r'\"price\"', content, re.IGNORECASE) and\n            re.search(r'\"pricecurrency\"', content, re.IGNORECASE)):\n            return True\n\n#       On some pages this is really terribly expensive when they dont really need it\n#       (For example you never want price monitoring, but this runs on every watch to suggest it)\n#        for filter in LD_JSON_PRODUCT_OFFER_SELECTORS:\n#            pricing_data += extract_json_as_string(content=content,\n#                                                  json_filter=filter,\n#                                                  ensure_is_ldjson_info_type=\"product\")\n    except Exception as e:\n        # OK too\n        return False\n\n    return False\n\n\n\ndef workarounds_for_obfuscations(content):\n    \"\"\"\n    Some sites are using sneaky tactics to make prices and other information un-renderable by Inscriptis\n    This could go into its own Pip package in the future, for faster updates\n    \"\"\"\n\n    # HomeDepot.com style <span>$<!-- -->90<!-- -->.<!-- -->74</span>\n    # https://github.com/weblyzard/inscriptis/issues/45\n    if not content:\n        return content\n\n    content = re.sub('<!--\\s+-->', '', content)\n\n    return content\n\n\ndef get_triggered_text(content, trigger_text):\n    triggered_text = []\n    result = strip_ignore_text(content=content,\n                               wordlist=trigger_text,\n                               mode=\"line numbers\")\n\n    i = 1\n    for p in content.splitlines():\n        if i in result:\n            triggered_text.append(p)\n        i += 1\n\n    return triggered_text\n\n\ndef extract_title(data: bytes | str, sniff_bytes: int = 2048, scan_chars: int = 8192) -> str | None:\n    try:\n        # Only decode/process the prefix we need for title extraction\n        match data:\n            case bytes() if data.startswith((b\"\\xff\\xfe\", b\"\\xfe\\xff\")):\n                prefix = data[:scan_chars * 2].decode(\"utf-16\", errors=\"replace\")\n            case bytes() if data.startswith((b\"\\xff\\xfe\\x00\\x00\", b\"\\x00\\x00\\xfe\\xff\")):\n                prefix = data[:scan_chars * 4].decode(\"utf-32\", errors=\"replace\")\n            case bytes():\n                try:\n                    prefix = data[:scan_chars].decode(\"utf-8\")\n                except UnicodeDecodeError:\n                    try:\n                        head = data[:sniff_bytes].decode(\"ascii\", errors=\"ignore\")\n                        if m := (META_CS.search(head) or META_CT.search(head)):\n                            enc = m.group(1).lower()\n                        else:\n                            enc = \"cp1252\"\n                        prefix = data[:scan_chars * 2].decode(enc, errors=\"replace\")\n                    except Exception as e:\n                        logger.error(f\"Title extraction encoding detection failed: {e}\")\n                        return None\n            case str():\n                prefix = data[:scan_chars] if len(data) > scan_chars else data\n            case _:\n                logger.error(f\"Title extraction received unsupported data type: {type(data)}\")\n                return None\n\n        # Search only in the prefix\n        if m := TITLE_RE.search(prefix):\n            title = html.unescape(\" \".join(m.group(1).split())).strip()\n            # Some safe limit\n            return title[:2000]\n        return None\n        \n    except Exception as e:\n        logger.error(f\"Title extraction failed: {e}\")\n        return None"
  },
  {
    "path": "changedetectionio/is_safe_url.py",
    "content": "\"\"\"\nURL redirect validation module for preventing open redirect vulnerabilities.\n\nThis module provides functionality to safely validate redirect URLs, ensuring they:\n1. Point to internal routes only (no external redirects)\n2. Are properly normalized (preventing browser parsing differences)\n3. Match registered Flask routes (no fake/non-existent pages)\n4. Are fully logged for security monitoring\n\nReferences:\n- https://flask-login.readthedocs.io/ (safe redirect patterns)\n- https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-v-user-logins\n- https://www.pythonkitchen.com/how-prevent-open-redirect-vulnerab-flask/\n\"\"\"\n\nfrom urllib.parse import urlparse, urljoin\nfrom flask import request\nfrom loguru import logger\n\n\ndef is_safe_url(target, app):\n    \"\"\"\n    Validate that a redirect URL is safe to prevent open redirect vulnerabilities.\n\n    This follows Flask/Werkzeug best practices by ensuring the redirect URL:\n    1. Is a relative path starting with exactly one '/'\n    2. Does not start with '//' (double-slash attack)\n    3. Has no external protocol handlers\n    4. Points to a valid registered route in the application\n    5. Is properly normalized to prevent browser parsing differences\n\n    Args:\n        target: The URL to validate (e.g., '/settings', '/login#top')\n        app: The Flask application instance (needed for route validation)\n\n    Returns:\n        bool: True if the URL is safe for redirection, False otherwise\n\n    Examples:\n        >>> is_safe_url('/settings', app)\n        True\n        >>> is_safe_url('//evil.com', app)\n        False\n        >>> is_safe_url('/settings#general', app)\n        True\n        >>> is_safe_url('/fake-page', app)\n        False\n    \"\"\"\n    if not target:\n        return False\n\n    # Normalize the URL to prevent browser parsing differences\n    # Strip whitespace and replace backslashes (which some browsers interpret as forward slashes)\n    target = target.strip()\n    target = target.replace('\\\\', '/')\n\n    # First, check if it starts with // or more (double-slash attack)\n    if target.startswith('//'):\n        logger.warning(f\"Blocked redirect attempt with double-slash: {target}\")\n        return False\n\n    # Parse the URL to check for scheme and netloc\n    parsed = urlparse(target)\n\n    # Block any URL with a scheme (http://, https://, javascript:, etc.)\n    if parsed.scheme:\n        logger.warning(f\"Blocked redirect attempt with scheme: {target}\")\n        return False\n\n    # Block any URL with a network location (netloc)\n    # This catches patterns like //evil.com, user@host, etc.\n    if parsed.netloc:\n        logger.warning(f\"Blocked redirect attempt with netloc: {target}\")\n        return False\n\n    # At this point, we have a relative URL with no scheme or netloc\n    # Use urljoin to resolve it and verify it points to the same host\n    ref_url = urlparse(request.host_url)\n    test_url = urlparse(urljoin(request.host_url, target))\n\n    # Check: ensure the resolved URL has the same netloc as current host\n    if not (test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc):\n        logger.warning(f\"Blocked redirect attempt with mismatched netloc: {target}\")\n        return False\n\n    # Additional validation: Check if the URL matches a registered route\n    # This prevents redirects to non-existent pages or unintended endpoints\n    try:\n        # Get the path without query string and fragment\n        # Fragments (like #general) are automatically stripped by urlparse\n        path = parsed.path\n\n        # Create a URL adapter bound to the server name\n        adapter = app.url_map.bind(ref_url.netloc)\n\n        # Try to match the path to a registered route\n        # This will raise NotFound if the route doesn't exist\n        endpoint, values = adapter.match(path, return_rule=False)\n\n        # Block redirects to static file endpoints - these are catch-all routes\n        # that would match arbitrary paths, potentially allowing unintended redirects\n        if endpoint in ('static_content', 'static', 'static_flags'):\n            logger.warning(f\"Blocked redirect to static endpoint: {target}\")\n            return False\n\n        # Successfully matched a valid route\n        logger.debug(f\"Validated safe redirect to endpoint '{endpoint}': {target}\")\n        return True\n\n    except Exception as e:\n        # Route doesn't exist or can't be matched\n        logger.warning(f\"Blocked redirect to non-existent route: {target} (error: {e})\")\n        return False\n"
  },
  {
    "path": "changedetectionio/jinja2_custom/__init__.py",
    "content": "\"\"\"\nJinja2 custom extensions and safe rendering utilities.\n\"\"\"\nfrom .extensions.TimeExtension import TimeExtension\nfrom .safe_jinja import (\n    render,\n    render_fully_escaped,\n    create_jinja_env,\n    JINJA2_MAX_RETURN_PAYLOAD_SIZE,\n    DEFAULT_JINJA2_EXTENSIONS,\n)\nfrom .plugins.regex import regex_replace\n\n__all__ = [\n    'TimeExtension',\n    'render',\n    'render_fully_escaped',\n    'create_jinja_env',\n    'JINJA2_MAX_RETURN_PAYLOAD_SIZE',\n    'DEFAULT_JINJA2_EXTENSIONS',\n    'regex_replace',\n]\n"
  },
  {
    "path": "changedetectionio/jinja2_custom/extensions/TimeExtension.py",
    "content": "\"\"\"\nJinja2 TimeExtension - Custom date/time handling for templates.\n\nThis extension provides the {% now %} tag for Jinja2 templates, offering timezone-aware\ndate/time formatting with support for time offsets.\n\nWhy This Extension Exists:\n    The Arrow library has a now() function (arrow.now()), but Jinja2 templates cannot\n    directly call Python functions - they need extensions or filters to expose functionality.\n\n    This TimeExtension serves as a Jinja2-to-Arrow bridge that:\n\n    1. Makes Arrow accessible in templates - Jinja2 requires registering functions/tags\n       through extensions. You cannot use arrow.now() directly in a template.\n\n    2. Provides template-friendly syntax - Instead of complex Python code, you get clean tags:\n       {% now 'UTC' %}\n       {% now 'UTC' + 'hours=2' %}\n       {% now 'Europe/London', '%Y-%m-%d' %}\n\n    3. Adds convenience features on top of Arrow:\n       - Default timezone from environment variable (TZ) or config\n       - Default datetime format configuration\n       - Offset syntax parsing: 'hours=2,minutes=30' → shift(hours=2, minutes=30)\n       - Empty string timezone support to use configured defaults\n\n    4. Maintains security - Works within Jinja2's sandboxed environment so users\n       cannot access arbitrary Python code or objects.\n\n    Essentially, this is a Jinja2 wrapper around arrow.now() and arrow.shift() that\n    provides user-friendly template syntax while maintaining security.\n\nBasic Usage:\n    {% now 'UTC' %}\n    # Output: Wed, 09 Dec 2015 23:33:01\n\nCustom Format:\n    {% now 'UTC', '%Y-%m-%d %H:%M:%S' %}\n    # Output: 2015-12-09 23:33:01\n\nTimezone Support:\n    {% now 'America/New_York' %}\n    {% now 'Europe/London' %}\n    {% now '' %}  # Uses default timezone from environment.default_timezone\n\nTime Offsets (Addition):\n    {% now 'UTC' + 'hours=2' %}\n    {% now 'UTC' + 'hours=2,minutes=30' %}\n    {% now 'UTC' + 'days=1,hours=2,minutes=15,seconds=10' %}\n\nTime Offsets (Subtraction):\n    {% now 'UTC' - 'minutes=11' %}\n    {% now 'UTC' - 'days=2,minutes=33,seconds=1' %}\n\nTime Offsets with Custom Format:\n    {% now 'UTC' + 'hours=2', '%Y-%m-%d %H:%M:%S' %}\n    # Output: 2015-12-10 01:33:01\n\nWeekday Support (for finding next/previous weekday):\n    {% now 'UTC' + 'weekday=0' %}  # Next Monday (0=Monday, 6=Sunday)\n    {% now 'UTC' + 'weekday=4' %}  # Next Friday\n\nConfiguration:\n    - Default timezone: Set via TZ environment variable or override environment.default_timezone\n    - Default format: '%a, %d %b %Y %H:%M:%S' (can be overridden via environment.datetime_format)\n\nEnvironment Customization:\n    from changedetectionio.jinja2_custom import create_jinja_env\n\n    jinja2_env = create_jinja_env()\n    jinja2_env.default_timezone = 'America/New_York'  # Override default timezone\n    jinja2_env.datetime_format = '%Y-%m-%d %H:%M'      # Override default format\n\nSupported Offset Parameters:\n    - years, months, weeks, days\n    - hours, minutes, seconds, microseconds\n    - weekday (0=Monday through 6=Sunday, must be integer)\n\nNote:\n    This extension uses the Arrow library for timezone-aware datetime handling.\n    All timezone names should be valid IANA timezone identifiers (e.g., 'America/New_York').\n\"\"\"\nimport arrow\n\nfrom jinja2 import nodes\nfrom jinja2.ext import Extension\nimport os\n\nclass TimeExtension(Extension):\n    \"\"\"\n    Jinja2 Extension providing the {% now %} tag for timezone-aware date/time rendering.\n\n    This extension adds two attributes to the Jinja2 environment:\n    - datetime_format: Default strftime format string (default: '%a, %d %b %Y %H:%M:%S')\n    - default_timezone: Default timezone for rendering (default: TZ env var or 'UTC')\n\n    Both can be overridden after environment creation by setting the attributes directly.\n    \"\"\"\n\n    tags = {'now'}\n\n    def __init__(self, environment):\n        \"\"\"Jinja2 Extension constructor.\"\"\"\n        super().__init__(environment)\n\n        environment.extend(\n            datetime_format='%a, %d %b %Y %H:%M:%S',\n            default_timezone=os.getenv('TZ', 'UTC').strip()\n        )\n\n    def _datetime(self, timezone, operator, offset, datetime_format):\n        \"\"\"\n        Get current datetime with time offset applied.\n\n        Args:\n            timezone: IANA timezone identifier (e.g., 'UTC', 'America/New_York') or empty string for default\n            operator: '+' for addition or '-' for subtraction\n            offset: Comma-separated offset parameters (e.g., 'hours=2,minutes=30')\n            datetime_format: strftime format string or None to use environment default\n\n        Returns:\n            Formatted datetime string with offset applied\n\n        Example:\n            _datetime('UTC', '+', 'hours=2,minutes=30', '%Y-%m-%d %H:%M:%S')\n            # Returns current time + 2.5 hours\n        \"\"\"\n        # Use default timezone if none specified\n        if not timezone or timezone == '':\n            timezone = self.environment.default_timezone\n\n        d = arrow.now(timezone)\n\n        # parse shift params from offset and include operator\n        shift_params = {}\n        for param in offset.split(','):\n            interval, value = param.split('=')\n            shift_params[interval.strip()] = float(operator + value.strip())\n\n        # Fix weekday parameter can not be float\n        if 'weekday' in shift_params:\n            shift_params['weekday'] = int(shift_params['weekday'])\n\n        d = d.shift(**shift_params)\n\n        if datetime_format is None:\n            datetime_format = self.environment.datetime_format\n        return d.strftime(datetime_format)\n\n    def _now(self, timezone, datetime_format):\n        \"\"\"\n        Get current datetime without any offset.\n\n        Args:\n            timezone: IANA timezone identifier (e.g., 'UTC', 'America/New_York') or empty string for default\n            datetime_format: strftime format string or None to use environment default\n\n        Returns:\n            Formatted datetime string for current time\n\n        Example:\n            _now('America/New_York', '%Y-%m-%d %H:%M:%S')\n            # Returns current time in New York timezone\n        \"\"\"\n        # Use default timezone if none specified\n        if not timezone or timezone == '':\n            timezone = self.environment.default_timezone\n\n        if datetime_format is None:\n            datetime_format = self.environment.datetime_format\n        return arrow.now(timezone).strftime(datetime_format)\n\n    def parse(self, parser):\n        \"\"\"\n        Parse the {% now %} tag and generate appropriate AST nodes.\n\n        This method is called by Jinja2 when it encounters a {% now %} tag.\n        It parses the tag syntax and determines whether to call _now() or _datetime()\n        based on whether offset operations (+ or -) are present.\n\n        Supported syntax:\n            {% now 'timezone' %}                              -> calls _now()\n            {% now 'timezone', 'format' %}                    -> calls _now()\n            {% now 'timezone' + 'offset' %}                   -> calls _datetime()\n            {% now 'timezone' + 'offset', 'format' %}         -> calls _datetime()\n            {% now 'timezone' - 'offset', 'format' %}         -> calls _datetime()\n\n        Args:\n            parser: Jinja2 parser instance\n\n        Returns:\n            nodes.Output: AST output node containing the formatted datetime string\n        \"\"\"\n        lineno = next(parser.stream).lineno\n\n        node = parser.parse_expression()\n\n        if parser.stream.skip_if('comma'):\n            datetime_format = parser.parse_expression()\n        else:\n            datetime_format = nodes.Const(None)\n\n        if isinstance(node, nodes.Add):\n            call_method = self.call_method(\n                '_datetime',\n                [node.left, nodes.Const('+'), node.right, datetime_format],\n                lineno=lineno,\n            )\n        elif isinstance(node, nodes.Sub):\n            call_method = self.call_method(\n                '_datetime',\n                [node.left, nodes.Const('-'), node.right, datetime_format],\n                lineno=lineno,\n            )\n        else:\n            call_method = self.call_method(\n                '_now',\n                [node, datetime_format],\n                lineno=lineno,\n            )\n        return nodes.Output([call_method], lineno=lineno)"
  },
  {
    "path": "changedetectionio/jinja2_custom/extensions/__init__.py",
    "content": ""
  },
  {
    "path": "changedetectionio/jinja2_custom/plugins/__init__.py",
    "content": "\"\"\"\nJinja2 custom filter plugins for changedetection.io\n\"\"\"\nfrom .regex import regex_replace\n\n__all__ = ['regex_replace']\n"
  },
  {
    "path": "changedetectionio/jinja2_custom/plugins/regex.py",
    "content": "\"\"\"\nRegex filter plugin for Jinja2 templates.\n\nProvides regex_replace filter for pattern-based string replacements in templates.\n\"\"\"\nimport re\nimport signal\nfrom loguru import logger\n\n\ndef regex_replace(value: str, pattern: str, replacement: str = '', count: int = 0) -> str:\n    \"\"\"\n    Replace occurrences of a regex pattern in a string.\n\n    Security: Protected against ReDoS (Regular Expression Denial of Service) attacks:\n    - Limits input value size to prevent excessive processing\n    - Uses timeout mechanism to prevent runaway regex operations\n    - Validates pattern complexity to prevent catastrophic backtracking\n\n    Args:\n        value: The input string to perform replacements on\n        pattern: The regex pattern to search for\n        replacement: The replacement string (default: '')\n        count: Maximum number of replacements (0 = replace all, default: 0)\n\n    Returns:\n        String with replacements applied, or original value on error\n\n    Example:\n        {{ \"hello world\" | regex_replace(\"world\", \"universe\") }}\n        {{ diff | regex_replace(\"<td>([^<]+)</td><td>([^<]+)</td>\", \"Label1: \\\\1\\\\nLabel2: \\\\2\") }}\n\n    Security limits:\n        - Maximum input size: 10MB\n        - Maximum pattern length: 500 characters\n        - Operation timeout: 10 seconds\n        - Dangerous nested quantifier patterns are rejected\n    \"\"\"\n    # Security limits\n    MAX_INPUT_SIZE = 1024 * 1024 * 10 # 10MB max input size\n    MAX_PATTERN_LENGTH = 500  # Maximum regex pattern length\n    REGEX_TIMEOUT_SECONDS = 10  # Maximum time for regex operation\n\n    # Validate input sizes\n    value_str = str(value)\n    if len(value_str) > MAX_INPUT_SIZE:\n        logger.warning(f\"regex_replace: Input too large ({len(value_str)} bytes), truncating\")\n        value_str = value_str[:MAX_INPUT_SIZE]\n\n    if len(pattern) > MAX_PATTERN_LENGTH:\n        logger.warning(f\"regex_replace: Pattern too long ({len(pattern)} chars), rejecting\")\n        return value_str\n\n    # Check for potentially dangerous patterns (basic checks)\n    # Nested quantifiers like (a+)+ can cause catastrophic backtracking\n    dangerous_patterns = [\n        r'\\([^)]*\\+[^)]*\\)\\+',  # (x+)+\n        r'\\([^)]*\\*[^)]*\\)\\+',  # (x*)+\n        r'\\([^)]*\\+[^)]*\\)\\*',  # (x+)*\n        r'\\([^)]*\\*[^)]*\\)\\*',  # (x*)*\n    ]\n\n    for dangerous in dangerous_patterns:\n        if re.search(dangerous, pattern):\n            logger.warning(f\"regex_replace: Potentially dangerous pattern detected: {pattern}\")\n            return value_str\n\n    def timeout_handler(signum, frame):\n        raise TimeoutError(\"Regex operation timed out\")\n\n    try:\n        # Set up timeout for regex operation (Unix-like systems only)\n        # This prevents ReDoS attacks\n        old_handler = None\n        if hasattr(signal, 'SIGALRM'):\n            old_handler = signal.signal(signal.SIGALRM, timeout_handler)\n            signal.alarm(REGEX_TIMEOUT_SECONDS)\n\n        try:\n            result = re.sub(pattern, replacement, value_str, count=count)\n        finally:\n            # Cancel the alarm\n            if hasattr(signal, 'SIGALRM'):\n                signal.alarm(0)\n                if old_handler is not None:\n                    signal.signal(signal.SIGALRM, old_handler)\n\n        return result\n\n    except TimeoutError:\n        logger.error(f\"regex_replace: Regex operation timed out - possible ReDoS attack. Pattern: {pattern}\")\n        return value_str\n    except re.error as e:\n        logger.warning(f\"regex_replace: Invalid regex pattern: {e}\")\n        return value_str\n    except Exception as e:\n        logger.error(f\"regex_replace: Unexpected error: {e}\")\n        return value_str\n"
  },
  {
    "path": "changedetectionio/jinja2_custom/safe_jinja.py",
    "content": "\"\"\"\nSafe Jinja2 render with max payload sizes\n\nSee https://jinja.palletsprojects.com/en/3.1.x/sandbox/#security-considerations\n\"\"\"\n\nimport jinja2.sandbox\nimport typing as t\nimport os\nfrom .extensions.TimeExtension import TimeExtension\nfrom .plugins import regex_replace\n\nJINJA2_MAX_RETURN_PAYLOAD_SIZE = 1024 * int(os.getenv(\"JINJA2_MAX_RETURN_PAYLOAD_SIZE_KB\", 1024 * 10))\n\n# Default extensions - can be overridden in create_jinja_env()\nDEFAULT_JINJA2_EXTENSIONS = [TimeExtension]\n\ndef create_jinja_env(extensions=None, **kwargs) -> jinja2.sandbox.ImmutableSandboxedEnvironment:\n    \"\"\"\n    Create a sandboxed Jinja2 environment with our custom extensions and default timezone.\n\n    Args:\n        extensions: List of extension classes to use (defaults to DEFAULT_JINJA2_EXTENSIONS)\n        **kwargs: Additional arguments to pass to ImmutableSandboxedEnvironment\n\n    Returns:\n        Configured Jinja2 environment\n    \"\"\"\n    if extensions is None:\n        extensions = DEFAULT_JINJA2_EXTENSIONS\n\n    jinja2_env = jinja2.sandbox.ImmutableSandboxedEnvironment(\n        extensions=extensions,\n        **kwargs\n    )\n\n    # Get default timezone from environment variable\n    default_timezone = os.getenv('TZ', 'UTC').strip()\n    jinja2_env.default_timezone = default_timezone\n\n    # Register custom filters\n    jinja2_env.filters['regex_replace'] = regex_replace\n\n    return jinja2_env\n\n\n# This is used for notifications etc, so actually it's OK to send custom HTML such as <a href> etc, but it should limit what data is available.\n# (Which also limits available functions that could be called)\ndef render(template_str, **args: t.Any) -> str:\n    jinja2_env = create_jinja_env()\n    output = jinja2_env.from_string(template_str).render(args)\n    return output[:JINJA2_MAX_RETURN_PAYLOAD_SIZE]\n\ndef render_fully_escaped(content):\n    \"\"\"\n    Escape HTML content safely.\n\n    MEMORY LEAK FIX: Use markupsafe.escape() directly instead of creating\n    Jinja2 environments (was causing 1M+ compilations per page load).\n    Simpler, faster, and no concerns about environment state.\n    \"\"\"\n    from markupsafe import escape\n    return str(escape(content))\n\n"
  },
  {
    "path": "changedetectionio/languages.py",
    "content": "\"\"\"\nLanguage configuration for i18n support\nAutomatically discovers available languages from translations directory\n\"\"\"\nimport os\nfrom pathlib import Path\n\n\ndef get_timeago_locale(flask_locale):\n    \"\"\"\n    Convert Flask-Babel locale codes to timeago library locale codes.\n\n    The Python timeago library (https://github.com/hustcc/timeago) supports 48 locales\n    but uses different naming conventions than Flask-Babel. This function maps between them.\n\n    Notable differences:\n    - Chinese: Flask uses 'zh', timeago uses 'zh_CN'\n    - Portuguese: Flask uses 'pt', timeago uses 'pt_PT' or 'pt_BR'\n    - Swedish: Flask uses 'sv', timeago uses 'sv_SE'\n    - Norwegian: Flask uses 'no', timeago uses 'nb_NO' or 'nn_NO'\n    - Hindi: Flask uses 'hi', timeago uses 'in_HI'\n    - Czech: Flask uses 'cs', but timeago doesn't support Czech - fallback to English\n\n    Args:\n        flask_locale (str): Flask-Babel locale code (e.g., 'cs', 'zh', 'pt')\n\n    Returns:\n        str: timeago library locale code (e.g., 'en', 'zh_CN', 'pt_PT')\n    \"\"\"\n    locale_map = {\n        'zh': 'zh_CN',      # Chinese Simplified\n        # timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.\n        'zh_TW': 'zh_TW',   # Chinese Traditional (timeago uses zh_TW)\n        'zh_Hant_TW': 'zh_TW',  # Flask-Babel normalizes zh_TW to zh_Hant_TW, map back to timeago's zh_TW\n        'pt': 'pt_PT',      # Portuguese (Portugal)\n        'sv': 'sv_SE',      # Swedish\n        'no': 'nb_NO',      # Norwegian Bokmål\n        'hi': 'in_HI',      # Hindi\n        'cs': 'en',         # Czech not supported by timeago, fallback to English\n        'uk': 'uk',         # Ukrainian\n        'en_GB': 'en',      # British English - timeago uses 'en'\n        'en_US': 'en',      # American English - timeago uses 'en'\n    }\n    return locale_map.get(flask_locale, flask_locale)\n\n# Language metadata: flag icon CSS class and native name\n# Using flag-icons library: https://flagicons.lipis.dev/\nLANGUAGE_DATA = {\n    'en_GB': {'flag': 'fi fi-gb fis', 'name': 'English (UK)'},\n    'en_US': {'flag': 'fi fi-us fis', 'name': 'English (US)'},\n    'de': {'flag': 'fi fi-de fis', 'name': 'Deutsch'},\n    'fr': {'flag': 'fi fi-fr fis', 'name': 'Français'},\n    'ko': {'flag': 'fi fi-kr fis', 'name': '한국어'},\n    'cs': {'flag': 'fi fi-cz fis', 'name': 'Čeština'},\n    'es': {'flag': 'fi fi-es fis', 'name': 'Español'},\n    'pt': {'flag': 'fi fi-pt fis', 'name': 'Português'},\n    'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'},\n    'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'},\n    'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'},\n    'zh_Hant_TW': {'flag': 'fi fi-tw fis', 'name': '繁體中文'},\n    'ru': {'flag': 'fi fi-ru fis', 'name': 'Русский'},\n    'pl': {'flag': 'fi fi-pl fis', 'name': 'Polski'},\n    'nl': {'flag': 'fi fi-nl fis', 'name': 'Nederlands'},\n    'sv': {'flag': 'fi fi-se fis', 'name': 'Svenska'},\n    'da': {'flag': 'fi fi-dk fis', 'name': 'Dansk'},\n    'no': {'flag': 'fi fi-no fis', 'name': 'Norsk'},\n    'fi': {'flag': 'fi fi-fi fis', 'name': 'Suomi'},\n    'tr': {'flag': 'fi fi-tr fis', 'name': 'Türkçe'},\n    'ar': {'flag': 'fi fi-sa fis', 'name': 'العربية'},\n    'hi': {'flag': 'fi fi-in fis', 'name': 'हिन्दी'},\n    'uk': {'flag': 'fi fi-ua fis', 'name': 'Українська'},\n}\n\n\ndef get_available_languages():\n    \"\"\"\n    Discover available languages by scanning the translations directory\n    Returns a dict of available languages with their metadata\n    \"\"\"\n    translations_dir = Path(__file__).parent / 'translations'\n\n    available = {}\n\n    # Scan for translation directories\n    if translations_dir.exists():\n        for lang_dir in translations_dir.iterdir():\n            if lang_dir.is_dir() and lang_dir.name in LANGUAGE_DATA:\n                # Check if messages.po exists\n                po_file = lang_dir / 'LC_MESSAGES' / 'messages.po'\n                if po_file.exists():\n                    available[lang_dir.name] = LANGUAGE_DATA[lang_dir.name]\n\n    # If no English variants found, fall back to adding en_GB as default\n    if 'en_GB' not in available and 'en_US' not in available:\n        available['en_GB'] = LANGUAGE_DATA['en_GB']\n\n    return available\n\n\ndef get_language_codes():\n    \"\"\"Get list of available language codes\"\"\"\n    return list(get_available_languages().keys())\n\n\ndef get_flag_for_locale(locale):\n    \"\"\"Get flag emoji for a locale, or globe if unknown\"\"\"\n    return LANGUAGE_DATA.get(locale, {}).get('flag', '🌐')\n\n\ndef get_name_for_locale(locale):\n    \"\"\"Get native name for a locale\"\"\"\n    return LANGUAGE_DATA.get(locale, {}).get('name', locale.upper())\n"
  },
  {
    "path": "changedetectionio/model/App.py",
    "content": "from os import getenv\nfrom copy import deepcopy\n\nfrom changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT\nfrom changedetectionio.model.Tags import TagsDict\n\nfrom changedetectionio.notification import (\n    default_notification_body,\n    default_notification_format,\n    default_notification_title,\n)\n\n# Equal to or greater than this number of FilterNotFoundInResponse exceptions will trigger a filter-not-found notification\n_FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6\nDEFAULT_SETTINGS_HEADERS_USERAGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36'\n\n\n\nclass model(dict):\n    base_config = {\n            'note': \"Hello! If you change this file manually, please be sure to restart your changedetection.io instance!\",\n            'watching': {},\n            'settings': {\n                'headers': {\n                },\n                'requests': {\n                    'extra_proxies': [], # Configurable extra proxies via the UI\n                    'extra_browsers': [],  # Configurable extra proxies via the UI\n                    'jitter_seconds': 0,\n                    'proxy': None, # Preferred proxy connection\n                    'time_between_check': {'weeks': None, 'days': None, 'hours': 3, 'minutes': None, 'seconds': None},\n                    'timeout': int(getenv(\"DEFAULT_SETTINGS_REQUESTS_TIMEOUT\", \"45\")),  # Default 45 seconds\n                    'workers': int(getenv(\"DEFAULT_SETTINGS_REQUESTS_WORKERS\", \"5\")),  # Number of threads, lower is better for slow connections\n                    'default_ua': {\n                        'html_requests': getenv(\"DEFAULT_SETTINGS_HEADERS_USERAGENT\", DEFAULT_SETTINGS_HEADERS_USERAGENT),\n                        'html_webdriver': None,\n                    }\n                },\n                'application': {\n                    # Custom notification content\n                    'all_paused': False,\n                    'all_muted': False,\n                    'api_access_token_enabled': True,\n                    'base_url' : None,\n                    'empty_pages_are_a_change': False,\n                    'fetch_backend': getenv(\"DEFAULT_FETCH_BACKEND\", \"html_requests\"),\n                    'filter_failure_notification_threshold_attempts': _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT,\n                    'global_ignore_text': [], # List of text to ignore when calculating the comparison checksum\n                    'global_subtractive_selectors': [],\n                    'history_snapshot_max_length': None,\n                    'ignore_whitespace': True,\n                    'ignore_status_codes': False, #@todo implement, as ternary.\n                    'ssim_threshold': '0.96',  # Default SSIM threshold for screenshot comparison\n                    'notification_body': default_notification_body,\n                    'notification_format': default_notification_format,\n                    'notification_title': default_notification_title,\n                    'notification_urls': [], # Apprise URL list\n                    'pager_size': 50,\n                    'password': False,\n                    'render_anchor_tag_content': False,\n                    'rss_access_token': None,\n                    'rss_content_format': RSS_CONTENT_FORMAT_DEFAULT,\n                    'rss_template_type': 'system_default',\n                    'rss_template_override': None,\n                    'rss_diff_length': 5,\n                    'rss_hide_muted_watches': True,\n                    'rss_reader_mode': False,\n                    'scheduler_timezone_default': None,  # Default IANA timezone name\n                    'schema_version' : 0,\n                    'shared_diff_access': False,\n                    'strip_ignored_lines': False,\n                    'tags': None,  # Initialized in __init__ with real datastore_path\n                    'webdriver_delay': None , # Extra delay in seconds before extracting text\n                    'ui': {\n                        'use_page_title_in_list': True,\n                        'open_diff_in_new_tab': True,\n                        'socket_io_enabled': True,\n                        'favicons_enabled': True\n                    },\n                }\n            }\n        }\n\n    def __init__(self, *arg, datastore_path=None, **kw):\n        super(model, self).__init__(*arg, **kw)\n        # Capture any tags data passed in before base_config overwrites the structure\n        existing_tags = self.get('settings', {}).get('application', {}).get('tags') or {}\n        # CRITICAL: deepcopy to avoid sharing mutable objects between instances\n        self.update(deepcopy(self.base_config))\n        # TagsDict requires the real datastore_path at runtime (cannot be set at class-definition time)\n        if datastore_path is None:\n            raise ValueError(\"App.model() requires 'datastore_path' keyword argument\")\n        self['settings']['application']['tags'] = TagsDict(existing_tags, datastore_path=datastore_path)\n\n\ndef parse_headers_from_text_file(filepath):\n    headers = {}\n    with open(filepath, 'r', encoding='utf-8') as f:\n        for l in f.readlines():\n            l = l.strip()\n            if not l.startswith('#') and ':' in l:\n                (k, v) = l.split(':', 1)  # Split only on the first colon\n                headers[k.strip()] = v.strip()\n\n    return headers"
  },
  {
    "path": "changedetectionio/model/Tag.py",
    "content": "\"\"\"\nTag/Group domain model for organizing and overriding watch settings.\n\nARCHITECTURE NOTE: Configuration Override Hierarchy\n===================================================\n\nTags can override Watch settings when overrides_watch=True.\nCurrent implementation requires manual checking in processors:\n\n    for tag_uuid in watch.get('tags'):\n        tag = datastore['settings']['application']['tags'][tag_uuid]\n        if tag.get('overrides_watch'):\n            restock_settings = tag.get('restock_settings', {})\n            break\n\nWith Pydantic, this would be automatic via chain resolution:\n    Watch → Tag (first with overrides_watch) → Global\n\nSee: Watch.py model docstring for full Pydantic architecture explanation\nSee: processors/restock_diff/processor.py:184-192 for current manual implementation\n\"\"\"\n\nfrom changedetectionio.model import watch_base\nfrom changedetectionio.model.persistence import EntityPersistenceMixin\n\nclass model(EntityPersistenceMixin, watch_base):\n    \"\"\"\n    Tag domain model - groups watches and can override their settings.\n\n    Tags inherit from watch_base to reuse all the same fields as Watch.\n    When overrides_watch=True, tag settings take precedence over watch settings\n    for all watches in this tag/group.\n\n    Fields:\n        overrides_watch (bool): If True, this tag's settings override watch settings\n        title (str): Display name for this tag/group\n        uuid (str): Unique identifier\n        ... (all fields from watch_base can be set as tag-level overrides)\n\n    Resolution order when overrides_watch=True:\n        Watch.field → Tag.field (if overrides_watch) → Global.field\n    \"\"\"\n\n    def __init__(self, *arg, **kw):\n        # Parent class (watch_base) handles __datastore and __datastore_path\n        super(model, self).__init__(*arg, **kw)\n\n        self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')\n\n        if kw.get('default'):\n            self.update(kw['default'])\n            del kw['default']\n\n    # _save_to_disk() method provided by EntityPersistenceMixin\n    # commit() and _get_commit_data() methods inherited from watch_base\n    # Tag uses default _get_commit_data() (includes all keys)\n"
  },
  {
    "path": "changedetectionio/model/Tags.py",
    "content": "import os\nimport shutil\nfrom pathlib import Path\nfrom loguru import logger\n\n_SENTINEL = object()\n\n\nclass TagsDict(dict):\n    \"\"\"Dict subclass that removes the corresponding tag.json file when a tag is deleted.\"\"\"\n\n    def __init__(self, *args, datastore_path: str | os.PathLike, **kwargs) -> None:\n        self._datastore_path = Path(datastore_path)\n        super().__init__(*args, **kwargs)\n\n    def __delitem__(self, key: str) -> None:\n        super().__delitem__(key)\n        tag_dir = self._datastore_path / key\n        tag_json_file = tag_dir / \"tag.json\"\n        if not os.path.exists(tag_json_file):\n            logger.critical(f\"Aborting deletion of directory '{tag_dir}' because '{tag_json_file}' does not exist.\")\n            return\n        try:\n            shutil.rmtree(tag_dir)\n            logger.info(f\"Deleted tag directory for tag {key!r}\")\n        except FileNotFoundError:\n            pass\n        except OSError as e:\n            logger.error(f\"Failed to delete tag directory for tag {key!r}: {e}\")\n\n    def pop(self, key: str, default=_SENTINEL):\n        \"\"\"Remove and return tag, deleting its tag.json file. Raises KeyError if missing and no default given.\"\"\"\n        if key in self:\n            value = self[key]\n            del self[key]\n            return value\n        if default is _SENTINEL:\n            raise KeyError(key)\n        return default\n"
  },
  {
    "path": "changedetectionio/model/Watch.py",
    "content": "\"\"\"\nWatch domain model for change detection monitoring.\n\nARCHITECTURE NOTE: Configuration Override Hierarchy\n===================================================\n\nThis module implements Watch objects that inherit from dict (technical debt).\nThe dream architecture would use Pydantic for:\n\n1. CHAIN RESOLUTION (Watch → Tag → Global Settings)\n   - Current: Manual resolution scattered across codebase\n   - Future: @computed_field properties with automatic resolution\n   - Examples: resolved_fetch_backend, resolved_restock_settings, etc.\n\n2. DATABASE BACKEND ABSTRACTION\n   - Current: Domain model tightly coupled to file-based JSON storage\n   - Future: Domain model (Pydantic) separate from persistence layer\n   - Enables: Easy migration to PostgreSQL, MongoDB, etc.\n\n3. TYPE SAFETY & VALIDATION\n   - Current: Dict access with no compile-time checks\n   - Future: Type hints, IDE autocomplete, validation at boundaries\n\nSee class model docstring for detailed explanation and examples.\nSee: processors/restock_diff/processor.py:184-192 for manual resolution example\n\"\"\"\n\nfrom blinker import signal\nfrom changedetectionio.validate_url import is_safe_valid_url\n\nfrom changedetectionio.strtobool import strtobool\nfrom changedetectionio.jinja2_custom import render as jinja_render\nfrom . import watch_base\nfrom .persistence import EntityPersistenceMixin\nimport os\nimport re\nfrom pathlib import Path\nfrom loguru import logger\n\nfrom .. import jinja2_custom as safe_jinja\nfrom ..html_tools import TRANSLATE_WHITESPACE_TABLE\n\nFAVICON_RESAVE_THRESHOLD_SECONDS=86400\nBROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024*20))\n\n# Module-level favicon filename cache: data_dir → basename (or None)\n# Keyed by data_dir so it survives Watch object recreation, deepcopy, and concurrent requests.\n# Invalidated explicitly in bump_favicon() when a new favicon is saved.\n_FAVICON_FILENAME_CACHE: dict = {}\n\nminimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))\nmtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}\n\ndef _brotli_save(contents, filepath, mode=None, fallback_uncompressed=False):\n    \"\"\"\n    Save compressed data using native brotli with streaming compression.\n    Uses chunked compression to minimize peak memory usage and malloc_trim()\n    to force release of C-level memory back to the OS.\n\n    Args:\n        contents: data to compress (str or bytes)\n        filepath: destination file path\n        mode: brotli compression mode (e.g., brotli.MODE_TEXT)\n        fallback_uncompressed: if True, save uncompressed on failure; if False, raise exception\n\n    Returns:\n        str: actual filepath saved (may differ from input if fallback used)\n\n    Raises:\n        Exception: if compression fails and fallback_uncompressed is False\n    \"\"\"\n    import brotli\n    import gc\n    import ctypes\n\n    # Ensure contents are bytes\n    if isinstance(contents, str):\n        contents = contents.encode('utf-8')\n\n    try:\n        original_size = len(contents)\n        logger.debug(f\"Starting brotli streaming compression of {original_size} bytes.\")\n\n        # Create streaming compressor\n        compressor = brotli.Compressor(quality=6, mode=mode if mode is not None else brotli.MODE_GENERIC)\n\n        # Stream compress in chunks to minimize memory usage\n        chunk_size = 65536  # 64KB chunks\n        total_compressed_size = 0\n\n        with open(filepath, 'wb') as f:\n            # Process data in chunks\n            offset = 0\n            while offset < len(contents):\n                chunk = contents[offset:offset + chunk_size]\n                compressed_chunk = compressor.process(chunk)\n                if compressed_chunk:\n                    f.write(compressed_chunk)\n                    total_compressed_size += len(compressed_chunk)\n                offset += chunk_size\n\n            # Finalize compression - critical for proper cleanup\n            final_chunk = compressor.finish()\n            if final_chunk:\n                f.write(final_chunk)\n                total_compressed_size += len(final_chunk)\n\n        logger.debug(f\"Finished brotli compression - From {original_size} to {total_compressed_size} bytes.\")\n\n        # Cleanup: Delete compressor, force Python GC, then force C-level memory release\n        del compressor\n        gc.collect()\n\n        # Force release of C-level memory back to OS (since brotli is a C library)\n        try:\n            ctypes.CDLL('libc.so.6').malloc_trim(0)\n        except Exception:\n            pass  # malloc_trim not available on all systems (e.g., macOS)\n\n        return filepath\n\n    except Exception as e:\n        logger.error(f\"Brotli compression error: {e}\")\n\n        # Compression failed\n        if fallback_uncompressed:\n            logger.warning(f\"Brotli compression failed for {filepath}, saving uncompressed\")\n            fallback_path = filepath.replace('.br', '')\n            with open(fallback_path, 'wb') as f:\n                f.write(contents)\n            return fallback_path\n        else:\n            raise Exception(f\"Brotli compression failed for {filepath}: {e}\")\n\n\nclass model(EntityPersistenceMixin, watch_base):\n    \"\"\"\n    Watch domain model for monitoring URL changes.\n\n    Inherits from watch_base (which inherits dict) - see watch_base docstring for field documentation.\n\n    ## Configuration Override Hierarchy (Chain Resolution)\n\n    The dream architecture uses a 3-level resolution chain:\n        Watch settings → Tag/Group settings → Global settings\n\n    Current implementation is MANUAL (see processor.py:184-192 for example):\n        - Processors manually check watch.get('field')\n        - Then loop through watch.tags to find first tag with overrides_watch=True\n        - Finally fall back to datastore['settings']['application']['field']\n\n    FUTURE: Pydantic-based chain resolution would enable:\n\n        ```python\n        # Instead of manual resolution in every processor:\n        restock_settings = watch.get('restock_settings', {})\n        for tag_uuid in watch.get('tags'):\n            tag = datastore['settings']['application']['tags'][tag_uuid]\n            if tag.get('overrides_watch'):\n                restock_settings = tag.get('restock_settings', {})\n                break\n\n        # Clean computed properties with automatic resolution:\n        @computed_field\n        def resolved_restock_settings(self) -> dict:\n            if self.restock_settings:\n                return self.restock_settings\n            for tag_uuid in self.tags:\n                tag = self._datastore.get_tag(tag_uuid)\n                if tag.overrides_watch and tag.restock_settings:\n                    return tag.restock_settings\n            return self._datastore.settings.restock_settings or {}\n\n        # Usage: watch.resolved_restock_settings (automatic, type-safe, tested once)\n        ```\n\n    Benefits of Pydantic migration:\n        1. Single source of truth for resolution logic (not scattered across processors)\n        2. Type safety + IDE autocomplete (watch.resolved_fetch_backend vs dict navigation)\n        3. Database backend abstraction (domain model separate from persistence)\n        4. Automatic validation at boundaries\n        5. Self-documenting via type hints\n        6. Easy to test resolution independently\n\n    Resolution chain examples that would benefit:\n        - fetch_backend: watch → tag → global (see get_fetch_backend property)\n        - notification_urls: watch → tag → global\n        - time_between_check: watch → global (see threshold_seconds)\n        - restock_settings: watch → tag (see processors/restock_diff/processor.py:184-192)\n        - history_snapshot_max_length: watch → global (see save_history_blob:550-556)\n        - All processor_config_* settings could use tag overrides\n\n    ## Database Backend Abstraction with Pydantic\n\n    Current: Watch inherits dict, tightly coupled to file-based JSON storage\n    Future: Domain model (Watch) separate from persistence layer\n\n        ```python\n        # Domain model (database-agnostic)\n        class Watch(BaseModel):\n            uuid: str\n            url: str\n            # ... validation, business logic\n\n        # Pluggable backends\n        class DataStoreBackend(ABC):\n            def save_watch(self, watch: Watch): ...\n            def load_watch(self, uuid: str) -> Watch: ...\n\n        # Implementations: FileBackend, MongoBackend, PostgresBackend, etc.\n        ```\n\n    This would enable:\n        - Easy migration between storage backends (file → postgres → mongodb)\n        - Pydantic handles serialization/deserialization automatically\n        - Domain logic stays clean (no storage concerns in Watch methods)\n\n    ## Migration Path\n\n    Given existing codebase, incremental migration recommended:\n        1. Create Pydantic models alongside existing dict-based models\n        2. Add .to_pydantic() / .from_pydantic() bridge methods\n        3. Gradually migrate code to use Pydantic models\n        4. Remove dict inheritance once migration complete\n\n    See: watch_base docstring for technical debt discussion\n    See: processors/restock_diff/processor.py:184-192 for manual resolution example\n    See: Watch.py:550-556 for nested dict navigation that would become watch.resolved_*\n    \"\"\"\n    __newest_history_key = None\n    __history_n = 0\n    jitter_seconds = 0\n\n    def __init__(self, *arg, **kw):\n        # Validate __datastore before calling parent (Watch requires it)\n        if not kw.get('__datastore'):\n            raise ValueError(\"Watch object requires '__datastore' reference - cannot access global settings without it\")\n\n        # Parent class (watch_base) handles __datastore and __datastore_path\n        super(model, self).__init__(*arg, **kw)\n\n        if kw.get('default'):\n            self.update(kw['default'])\n            del kw['default']\n\n        if self.get('default'):\n            del self['default']\n\n        # Be sure the cached timestamp is ready\n        bump = self.history\n\n    # Note: __deepcopy__, __getstate__, and __setstate__ are inherited from watch_base\n    # This prevents memory leaks by sharing __datastore reference instead of copying it\n\n    @property\n    def viewed(self):\n        # Don't return viewed when last_viewed is 0 and newest_key is 0\n        if int(self['last_viewed']) and int(self['last_viewed']) >= int(self.newest_history_key) :\n            return True\n\n        return False\n\n    @property\n    def has_unviewed(self):\n        return int(self.newest_history_key) > int(self['last_viewed']) and self.__history_n >= 2\n\n    @property\n    def link(self):\n\n        url = self.get('url', '')\n        if not is_safe_valid_url(url):\n            return 'DISABLED'\n\n        ready_url = url\n        if '{%' in url or '{{' in url:\n            # Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/\n            try:\n                ready_url = jinja_render(template_str=url)\n            except Exception as e:\n                logger.critical(f\"Invalid URL template for: '{url}' - {str(e)}\")\n                from flask import flash, url_for\n                from markupsafe import Markup\n                message = Markup('<a href=\"{}#general\">The URL {} is invalid and cannot be used, click to edit</a>'.format(\n                    url_for('ui.ui_edit.edit_page', uuid=self.get('uuid')), self.get('url', '')))\n                flash(message, 'error')\n                return ''\n\n        if ready_url.startswith('source:'):\n            ready_url=ready_url.replace('source:', '')\n\n        # Also double check it after any Jinja2 formatting just incase\n        if not is_safe_valid_url(ready_url):\n            return 'DISABLED'\n        return ready_url\n\n    @property\n    def domain_only_from_link(self):\n        from urllib.parse import urlparse\n        parsed = urlparse(self.link)\n        domain = parsed.hostname\n        return domain\n\n    @property\n    def history_index_filename(self):\n        # So that you dont try to view different histories in different 'diff' setups, can confuse cdio.\n        processor = self.get('processor')\n        if not processor or self.get('processor') == 'text_json_diff':\n            return 'history.txt'\n        else:\n            return f'history-{processor}.txt'\n\n    def clear_watch(self):\n        import pathlib\n\n        # Get list of processor config files to preserve\n        from changedetectionio.processors import find_processors\n        processor_names = [name for cls, name in find_processors()]\n        processor_config_files = {f\"{name}.json\" for name in processor_names}\n\n        # JSON Data, Screenshots, Textfiles (history index and snapshots), HTML in the future etc\n        # But preserve processor config files (they're configuration, not history data)\n        # Use glob not rglob here for safety.\n        for item in pathlib.Path(str(self.data_dir)).glob(\"*.*\"):\n            # Skip processor config files\n            if item.name in processor_config_files:\n                continue\n            os.unlink(item)\n\n        # Force the attr to recalculate\n        bump = self.history\n\n        # Do this last because it will trigger a recheck due to last_checked being zero\n        self.update({\n            'browser_steps_last_error_step': None,\n            'check_count': 0,\n            'fetch_time': 0.0,\n            'has_ldjson_price_data': None,\n            'last_checked': 0,\n            'last_error': False,\n            'last_notification_error': False,\n            'last_viewed': 0,\n            'previous_md5': False,\n            'remote_server_reply': None,\n            'track_ldjson_price_data': None\n        })\n        watch_check_update = signal('watch_check_update')\n        if watch_check_update:\n            watch_check_update.send(watch_uuid=self.get('uuid'))\n\n        return\n\n    @property\n    def is_source_type_url(self):\n        return self.get('url', '').startswith('source:')\n\n    @property\n    def get_fetch_backend(self):\n        \"\"\"\n        Get the fetch backend for this watch with special case handling.\n\n        CHAIN RESOLUTION OPPORTUNITY:\n        Currently returns watch.fetch_backend directly, but doesn't implement\n        Watch → Tag → Global resolution chain. With Pydantic:\n\n        @computed_field\n        def resolved_fetch_backend(self) -> str:\n            # Special case: PDFs always use html_requests\n            if self.is_pdf:\n                return 'html_requests'\n\n            # Watch override\n            if self.fetch_backend and self.fetch_backend != 'system':\n                return self.fetch_backend\n\n            # Tag override (first tag with overrides_watch=True wins)\n            for tag_uuid in self.tags:\n                tag = self._datastore.get_tag(tag_uuid)\n                if tag.overrides_watch and tag.fetch_backend:\n                    return tag.fetch_backend\n\n            # Global default\n            return self._datastore.settings.fetch_backend\n        \"\"\"\n        # Maybe also if is_image etc?\n        # This is because chrome/playwright wont render the PDF in the browser and we will just fetch it and use pdf2html to see the text.\n        if self.is_pdf:\n            return 'html_requests'\n\n        return self.get('fetch_backend')\n\n    @property\n    def fetcher_supports_screenshots(self):\n        \"\"\"Return True if the fetcher configured for this watch supports screenshots.\n\n        Resolves 'system' via self._datastore, then checks supports_screenshots on\n        the actual fetcher class. Works for built-in and plugin fetchers alike.\n        \"\"\"\n        from changedetectionio import content_fetchers\n\n        fetcher_name = self.get_fetch_backend  # already handles is_pdf → html_requests\n        if not fetcher_name or fetcher_name == 'system':\n            fetcher_name = self._datastore['settings']['application'].get('fetch_backend', 'html_requests')\n\n        fetcher_class = getattr(content_fetchers, fetcher_name, None)\n        if fetcher_class is None:\n            return False\n\n        return bool(getattr(fetcher_class, 'supports_screenshots', False))\n\n    @property\n    def is_pdf(self):\n        url = str(self.get(\"url\") or \"\").lower()\n        content_type = str(self.get(\"content-type\") or \"\").lower()\n\n        if content_type in (\"none\", \"null\", \"\"):\n            content_type = \"\"\n\n        return (\n                url.endswith(\".pdf\")\n                or content_type.split(\";\")[0].strip() == \"application/pdf\"\n        )\n\n    @property\n    def label(self):\n        # Used for sorting, display, etc\n        return self.get('title') or self.get('page_title') or self.link\n\n    @property\n    def last_changed(self):\n        # last_changed will be the newest snapshot, but when we have just one snapshot, it should be 0\n        if self.__history_n <= 1:\n            return 0\n        if self.__newest_history_key:\n            return int(self.__newest_history_key)\n        return 0\n\n    @property\n    def history_n(self):\n        return self.__history_n\n\n    @property\n    def history(self):\n        \"\"\"History index is just a text file as a list\n            {watch-uuid}/history.txt\n\n            contains a list like\n\n            {epoch-time},{filename}\\n\n\n            We read in this list as the history information\n\n        \"\"\"\n        tmp_history = {}\n\n        # In the case we are only using the watch for processing without history\n        if not self.data_dir:\n            return []\n\n        # Read the history file as a dict\n        fname = os.path.join(self.data_dir, self.history_index_filename)\n        if os.path.isfile(fname):\n            logger.debug(f\"Reading watch history index for {self.get('uuid')}\")\n            with open(fname, \"r\", encoding='utf-8') as f:\n                for i in f.readlines():\n                    if ',' in i:\n                        k, v = i.strip().split(',', 2)\n\n                        # The index history could contain a relative path, so we need to make the fullpath\n                        # so that python can read it\n                        # Cross-platform: check for any path separator (works on Windows and Unix)\n                        if os.sep not in v and '/' not in v and '\\\\' not in v:\n                            # Relative filename only, no path separators\n                            v = os.path.join(self.data_dir, v)\n                        else:\n                            # It's possible that they moved the datadir on older versions\n                            # So the snapshot exists but is in a different path\n                            # Cross-platform: use os.path.basename instead of split('/')\n                            snapshot_fname = os.path.basename(v)\n                            proposed_new_path = os.path.join(self.data_dir, snapshot_fname)\n                            if not os.path.exists(v) and os.path.exists(proposed_new_path):\n                                v = proposed_new_path\n\n                        tmp_history[k] = v\n\n        if len(tmp_history):\n            self.__newest_history_key = list(tmp_history.keys())[-1]\n        else:\n            self.__newest_history_key = None\n\n        self.__history_n = len(tmp_history)\n\n        return tmp_history\n\n    @property\n    def has_history(self):\n        fname = os.path.join(self.data_dir, self.history_index_filename)\n        return os.path.isfile(fname)\n\n    @property\n    def has_browser_steps(self):\n        has_browser_steps = self.get('browser_steps') and list(filter(\n            lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'),\n            self.get('browser_steps')))\n\n        return has_browser_steps\n\n    @property\n    def has_restock_info(self):\n        if self.get('restock') and self['restock'].get('in_stock') != None:\n                return True\n\n        return False\n\n    # Returns the newest key, but if theres only 1 record, then it's counted as not being new, so return 0.\n    @property\n    def newest_history_key(self):\n        if self.__newest_history_key is not None:\n            return self.__newest_history_key\n\n        if len(self.history) <= 1:\n            return 0\n\n\n        bump = self.history\n        return self.__newest_history_key\n\n    # Given an arbitrary timestamp, find the best history key for the [diff] button so it can preset a smarter from_version\n    @property\n    def get_from_version_based_on_last_viewed(self):\n\n        \"\"\"Unfortunately for now timestamp is stored as string key\"\"\"\n        keys = list(self.history.keys())\n        if not keys:\n            return None\n        if len(keys) == 1:\n            return keys[0]\n\n        last_viewed = int(self.get('last_viewed'))\n        sorted_keys = sorted(keys, key=lambda x: int(x))\n        sorted_keys.reverse()\n\n        # When the 'last viewed' timestamp is greater than or equal the newest snapshot, return second newest\n        if last_viewed >= int(sorted_keys[0]):\n            return sorted_keys[1]\n        \n        # When the 'last viewed' timestamp is between snapshots, return the older snapshot\n        for newer, older in list(zip(sorted_keys[0:], sorted_keys[1:])):\n            if last_viewed < int(newer) and last_viewed >= int(older):\n                return older\n\n        # When the 'last viewed' timestamp is less than the oldest snapshot, return oldest\n        return sorted_keys[-1]\n\n    def get_history_snapshot(self, timestamp=None, filepath=None):\n        \"\"\"\n        Accepts either timestamp or filepath\n        :param timestamp:\n        :param filepath:\n        :return:\n        \"\"\"\n        import brotli\n\n        if not filepath:\n            filepath = self.history[timestamp]\n\n        # Check if binary file (image, PDF, etc.)\n        # Binary files are NEVER saved with .br compression, only text files are\n        binary_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf', '.bin', '.jfif')\n        is_binary = any(filepath.endswith(ext) for ext in binary_extensions)\n\n        # Only look for .br versions for text files\n        if not is_binary:\n            # See if a brotli version exists and switch to that (text files only)\n            if not filepath.endswith('.br') and os.path.isfile(f\"{filepath}.br\"):\n                filepath = f\"{filepath}.br\"\n\n            # OR in the backup case that the .br does not exist, but the plain one does\n            if filepath.endswith('.br') and not os.path.isfile(filepath):\n                if os.path.isfile(filepath.replace('.br', '')):\n                    filepath = filepath.replace('.br', '')\n\n        # Handle .br compressed text files\n        if filepath.endswith('.br'):\n            # Brotli doesnt have a fileheader to detect it, so we rely on filename\n            # https://www.rfc-editor.org/rfc/rfc7932\n            # Note: .br should ONLY exist for text files, never binary\n            with open(filepath, 'rb') as f:\n                return brotli.decompress(f.read()).decode('utf-8')\n\n        # Binary file - return raw bytes\n        if is_binary:\n            with open(filepath, 'rb') as f:\n                return f.read()\n\n        # Text file - decode to string\n        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:\n            return f.read()\n\n    def _write_atomic(self, dest, data, mode='wb'):\n        \"\"\"Write data atomically to dest using a temp file\"\"\"\n        import tempfile\n        with tempfile.NamedTemporaryFile(mode, delete=False, dir=self.data_dir) as tmp:\n            tmp.write(data)\n            tmp.flush()\n            os.fsync(tmp.fileno())\n            tmp_path = tmp.name\n        os.replace(tmp_path, dest)\n\n    def history_trim(self, newest_n_items):\n        from pathlib import Path\n        import gc\n        # Sort by timestamp (key)\n        sorted_items = sorted(self.history.items(), key=lambda x: int(x[0]))\n\n        keep_part = dict(sorted_items[-newest_n_items:])\n        delete_part = dict(sorted_items[:-newest_n_items])\n        logger.info( f\"[{self.get('uuid')}] Trimming history to most recent {newest_n_items} items, keeping {len(keep_part)} items deleting {len(delete_part)} items.\")\n\n        if delete_part:\n            for item in delete_part.items():\n                try:\n                    Path(item[1]).unlink(missing_ok=True)\n                except Exception as e:\n                    logger.critical(f\"{str(e)}\")\n                finally:\n                    logger.debug(f\"[{self.get('uuid')}] Deleted {item[1]} history snapshot\")\n        try:\n            dest = os.path.join(self.data_dir, self.history_index_filename)\n            output = \"\\r\\n\".join(\n                f\"{k},{Path(v).name}\"\n                for k, v in keep_part.items()\n            )+\"\\r\\n\"\n            self._write_atomic(dest=dest, data=output, mode='w')\n        except Exception as e:\n            logger.critical(f\"{str(e)}\")\n        finally:\n            logger.debug(f\"[{self.get('uuid')}] Updated history index {dest}\")\n\n        # reimport\n        bump = self.history\n        gc.collect()\n\n    # Save some text file to the appropriate path and bump the history\n    # result_obj from fetch_site_status.run()\n    def save_history_blob(self, contents, timestamp, snapshot_id):\n\n        logger.trace(f\"{self.get('uuid')} - Updating {self.history_index_filename} with timestamp {timestamp}\")\n\n        self.ensure_data_dir_exists()\n        skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))\n\n        # Binary data - detect file type and save without compression\n        if isinstance(contents, bytes):\n            try:\n                import puremagic\n                detections = puremagic.magic_string(contents[:2048])\n                ext = detections[0].extension if detections else 'bin'\n                # Strip leading dot if present (puremagic returns extensions like '.jfif')\n                ext = ext.lstrip('.')\n                if detections:\n                    logger.trace(f\"Detected file type: {detections[0].mime_type} -> extension: {ext}\")\n            except Exception as e:\n                logger.warning(f\"puremagic detection failed: {e}, using 'bin' extension\")\n                ext = 'bin'\n\n            snapshot_fname = f\"{snapshot_id}.{ext}\"\n            dest = os.path.join(self.data_dir, snapshot_fname)\n            self._write_atomic(dest, contents)\n            logger.trace(f\"Saved binary snapshot as {snapshot_fname} ({len(contents)} bytes)\")\n\n        # Text data - use brotli compression if enabled and above threshold\n        else:\n            if not skip_brotli and len(contents) > BROTLI_COMPRESS_SIZE_THRESHOLD:\n                # Compressed text\n                import brotli\n                snapshot_fname = f\"{snapshot_id}.txt.br\"\n                dest = os.path.join(self.data_dir, snapshot_fname)\n\n                if not os.path.exists(dest):\n                    try:\n                        actual_dest = _brotli_save(contents, dest, mode=brotli.MODE_TEXT, fallback_uncompressed=True)\n                        if actual_dest != dest:\n                            snapshot_fname = os.path.basename(actual_dest)\n                    except Exception as e:\n                        logger.error(f\"{self.get('uuid')} - Brotli compression failed: {e}\")\n                        # Fallback to uncompressed\n                        snapshot_fname = f\"{snapshot_id}.txt\"\n                        dest = os.path.join(self.data_dir, snapshot_fname)\n                        self._write_atomic(dest, contents.encode('utf-8'))\n            else:\n                # Plain text\n                snapshot_fname = f\"{snapshot_id}.txt\"\n                dest = os.path.join(self.data_dir, snapshot_fname)\n                self._write_atomic(dest, contents.encode('utf-8'))\n\n        # Append to history.txt atomically\n        index_fname = os.path.join(self.data_dir, self.history_index_filename)\n        index_line = f\"{timestamp},{snapshot_fname}\\n\"\n\n        with open(index_fname, 'a', encoding='utf-8') as f:\n            f.write(index_line)\n            f.flush()\n            os.fsync(f.fileno())\n\n        # Update internal state\n        self.__newest_history_key = timestamp\n        self.__history_n += 1\n\n        # MANUAL CHAIN RESOLUTION: Watch → Global\n        # With Pydantic, this would become: maxlen = watch.resolved_history_snapshot_max_length\n        # @computed_field def resolved_history_snapshot_max_length(self) -> Optional[int]:\n        #     if self.history_snapshot_max_length: return self.history_snapshot_max_length\n        #     if tag := self._get_override_tag(): return tag.history_snapshot_max_length\n        #     return self._datastore.settings.history_snapshot_max_length\n        maxlen = self.get('history_snapshot_max_length') or self.get_global_setting('application', 'history_snapshot_max_length')\n\n        if maxlen and self.__history_n and self.__history_n > maxlen:\n            self.history_trim(newest_n_items=maxlen)\n\n        # @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status\n        return snapshot_fname\n\n    @property\n    def has_empty_checktime(self):\n        # using all() + dictionary comprehension\n        # Check if all values are 0 in dictionary\n        res = all(x == None or x == False or x==0 for x in self.get('time_between_check', {}).values())\n        return res\n\n    def threshold_seconds(self):\n        seconds = 0\n        for m, n in mtable.items():\n            x = self.get('time_between_check', {}).get(m, None)\n            if x:\n                seconds += x * n\n        return seconds\n\n    # Iterate over all history texts and see if something new exists\n    # Always applying .strip() to start/end but optionally replace any other whitespace\n    def lines_contain_something_unique_compared_to_history(self, lines: list, ignore_whitespace=False):\n        local_lines = set([])\n        if lines:\n            if ignore_whitespace:\n                if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk\n                    local_lines = set([l.translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines])\n                else:\n                    local_lines = set([l.decode('utf-8').translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines])\n            else:\n                if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk\n                    local_lines = set([l.strip().lower() for l in lines])\n                else:\n                    local_lines = set([l.decode('utf-8').strip().lower() for l in lines])\n\n\n        # Compare each lines (set) against each history text file (set) looking for something new..\n        existing_history = set({})\n        for k, v in self.history.items():\n            content = self.get_history_snapshot(filepath=v)\n\n            if ignore_whitespace:\n                alist = set([line.translate(TRANSLATE_WHITESPACE_TABLE).lower() for line in content.splitlines()])\n            else:\n                alist = set([line.strip().lower() for line in content.splitlines()])\n\n            existing_history = existing_history.union(alist)\n\n        # Check that everything in local_lines(new stuff) already exists in existing_history - it should\n        # if not, something new happened\n        return not local_lines.issubset(existing_history)\n\n    def get_screenshot(self):\n        fname = os.path.join(self.data_dir, \"last-screenshot.png\")\n        if os.path.isfile(fname):\n            return fname\n\n        # False is not an option for AppRise, must be type None\n        return None\n\n    def favicon_is_expired(self):\n        favicon_fname = self.get_favicon_filename()\n        import glob\n        import time\n\n        if not favicon_fname:\n            return True\n        try:\n            fname = next(iter(glob.glob(os.path.join(self.data_dir, \"favicon.*\"))), None)\n            logger.trace(f\"Favicon file maybe found at {fname}\")\n            if os.path.isfile(fname):\n                file_age = int(time.time() - os.path.getmtime(fname))\n                logger.trace(f\"Favicon file age is {file_age}s\")\n                if file_age < FAVICON_RESAVE_THRESHOLD_SECONDS:\n                    return False\n        except Exception as e:\n            logger.critical(f\"Exception checking Favicon age {str(e)}\")\n            return True\n\n        # Also in the case that the file didnt exist\n        return True\n\n    def bump_favicon(self, url, favicon_base_64: str) -> None:\n        from urllib.parse import urlparse\n        import base64\n        import binascii\n        decoded = None\n\n        if url:\n            try:\n                parsed = urlparse(url)\n                filename = os.path.basename(parsed.path)\n                (base, extension) = filename.lower().strip().rsplit('.', 1)\n            except ValueError:\n                logger.error(f\"UUID: {self.get('uuid')} Cant work out file extension from '{url}'\")\n                return None\n        else:\n            # Assume favicon.ico\n            base = \"favicon\"\n            extension = \"ico\"\n\n        fname = os.path.join(self.data_dir, f\"favicon.{extension}\")\n\n        try:\n            # validate=True makes sure the string only contains valid base64 chars\n            decoded = base64.b64decode(favicon_base_64, validate=True)\n        except (binascii.Error, ValueError) as e:\n            logger.warning(f\"UUID: {self.get('uuid')} FavIcon save data (Base64) corrupt? {str(e)}\")\n        else:\n            if decoded:\n                try:\n                    with open(fname, 'wb') as f:\n                        f.write(decoded)\n\n                    # Invalidate module-level favicon filename cache for this watch\n                    _FAVICON_FILENAME_CACHE.pop(self.data_dir, None)\n\n                    # A signal that could trigger the socket server to update the browser also\n                    watch_check_update = signal('watch_favicon_bump')\n                    if watch_check_update:\n                        watch_check_update.send(watch_uuid=self.get('uuid'))\n\n                except Exception as e:\n                    logger.warning(f\"UUID: {self.get('uuid')} error saving FavIcon to {fname} - {str(e)}\")\n\n        # @todo - Store some checksum and only write when its different\n        logger.debug(f\"UUID: {self.get('uuid')} updated favicon to at {fname}\")\n\n    def get_favicon_filename(self) -> str | None:\n        \"\"\"\n        Find any favicon.* file in the watch data directory.\n\n        Uses a module-level cache keyed by data_dir to survive Watch object recreation,\n        deepcopy (which drops instance attrs), and concurrent request races.\n        Invalidated by bump_favicon() when a new favicon is saved.\n\n        Returns:\n            str: Basename of the favicon file, or None if not found.\n        \"\"\"\n        if self.data_dir in _FAVICON_FILENAME_CACHE:\n            return _FAVICON_FILENAME_CACHE[self.data_dir]\n\n        import glob\n        files = glob.glob(os.path.join(self.data_dir, \"favicon.*\"))\n        fname = os.path.basename(files[0]) if files else None\n        _FAVICON_FILENAME_CACHE[self.data_dir] = fname\n        return fname\n\n    def get_screenshot_as_thumbnail(self, max_age=3200):\n        \"\"\"Return path to a square thumbnail of the most recent screenshot.\n\n        Creates a 150x150 pixel thumbnail from the top portion of the screenshot.\n\n        Args:\n            max_age: Maximum age in seconds before recreating thumbnail\n\n        Returns:\n            Path to thumbnail or None if no screenshot exists\n        \"\"\"\n        import os\n        import time\n\n        thumbnail_path = os.path.join(self.data_dir, \"thumbnail.jpeg\")\n        top_trim = 500  # Pixels from top of screenshot to use\n\n        screenshot_path = self.get_screenshot()\n        if not screenshot_path:\n            return None\n\n        # Reuse thumbnail if it's fresh and screenshot hasn't changed\n        if os.path.isfile(thumbnail_path):\n            thumbnail_mtime = os.path.getmtime(thumbnail_path)\n            screenshot_mtime = os.path.getmtime(screenshot_path)\n\n            if screenshot_mtime <= thumbnail_mtime and time.time() - thumbnail_mtime < max_age:\n                return thumbnail_path\n\n        try:\n            from PIL import Image\n\n            with Image.open(screenshot_path) as img:\n                # Crop top portion first (full width, top_trim height)\n                top_crop_height = min(top_trim, img.height)\n                img = img.crop((0, 0, img.width, top_crop_height))\n\n                # Create a smaller intermediate image (to reduce memory usage)\n                aspect = img.width / img.height\n                interim_width = min(top_trim, img.width)\n                interim_height = int(interim_width / aspect) if aspect > 0 else top_trim\n                img = img.resize((interim_width, interim_height), Image.NEAREST)\n\n                # Convert to RGB if needed\n                if img.mode != 'RGB':\n                    img = img.convert('RGB')\n\n                # Crop to square from top center\n                square_size = min(img.width, img.height)\n                left = (img.width - square_size) // 2\n                img = img.crop((left, 0, left + square_size, square_size))\n\n                # Final resize to exact thumbnail size with better filter\n                img = img.resize((350, 350), Image.BILINEAR)\n\n                # Save with optimized settings\n                img.save(thumbnail_path, \"JPEG\", quality=75, optimize=True)\n\n            return thumbnail_path\n\n        except Exception as e:\n            logger.error(f\"Error creating thumbnail for {self.get('uuid')}: {str(e)}\")\n            return None\n\n    def __get_file_ctime(self, filename):\n        fname = os.path.join(self.data_dir, filename)\n        if os.path.isfile(fname):\n            return int(os.path.getmtime(fname))\n        return False\n\n    @property\n    def error_text_ctime(self):\n        return self.__get_file_ctime('last-error.txt')\n\n    @property\n    def snapshot_text_ctime(self):\n        if self.history_n==0:\n            return False\n\n        timestamp = list(self.history.keys())[-1]\n        return int(timestamp)\n\n    @property\n    def snapshot_screenshot_ctime(self):\n        return self.__get_file_ctime('last-screenshot.png')\n\n    @property\n    def snapshot_error_screenshot_ctime(self):\n        return self.__get_file_ctime('last-error-screenshot.png')\n\n    def get_error_text(self):\n        \"\"\"Return the text saved from a previous request that resulted in a non-200 error\"\"\"\n        fname = os.path.join(self.data_dir, \"last-error.txt\")\n        if os.path.isfile(fname):\n            with open(fname, 'r', encoding='utf-8') as f:\n                return f.read()\n        return False\n\n    def get_error_snapshot(self):\n        \"\"\"Return path to the screenshot that resulted in a non-200 error\"\"\"\n        fname = os.path.join(self.data_dir, \"last-error-screenshot.png\")\n        if os.path.isfile(fname):\n            return fname\n        return False\n\n\n    def pause(self):\n        self['paused'] = True\n\n    def unpause(self):\n        self['paused'] = False\n\n    def toggle_pause(self):\n        self['paused'] ^= True\n\n    def mute(self):\n        self['notification_muted'] = True\n\n    def unmute(self):\n        self['notification_muted'] = False\n\n    def toggle_mute(self):\n        self['notification_muted'] ^= True\n\n    def _get_commit_data(self):\n        \"\"\"\n        Prepare watch data for commit.\n\n        Excludes processor_config_* keys (stored in separate files).\n        Normalizes browser_steps to empty list if no meaningful steps.\n        \"\"\"\n        import copy\n\n        # Get base snapshot with lock\n        lock = self._datastore.lock if self._datastore and hasattr(self._datastore, 'lock') else None\n\n        if lock:\n            with lock:\n                snapshot = dict(self)\n        else:\n            snapshot = dict(self)\n\n        # Exclude processor config keys (stored separately)\n        watch_dict = {k: copy.deepcopy(v) for k, v in snapshot.items() if not k.startswith('processor_config_')}\n\n        # Normalize browser_steps: if no meaningful steps, save as empty list\n        if not self.has_browser_steps:\n            watch_dict['browser_steps'] = []\n\n        return watch_dict\n\n    # _save_to_disk() method provided by EntityPersistenceMixin\n    # commit() method inherited from watch_base\n\n\n    def extra_notification_token_values(self):\n        # Used for providing extra tokens\n        # return {'widget': 555}\n        return {}\n\n    def extra_notification_token_placeholder_info(self):\n        # Used for providing extra tokens\n        # return [('widget', \"Get widget amounts\")]\n        return []\n\n\n    def extract_regex_from_all_history(self, regex):\n        import csv\n        import re\n        import datetime\n        csv_output_filename = False\n        csv_writer = False\n        f = None\n\n        # self.history will be keyed with the full path\n        for k, fname in self.history.items():\n            if os.path.isfile(fname):\n                if True:\n                    contents = self.get_history_snapshot(timestamp=k)\n                    res = re.findall(regex, contents, re.MULTILINE)\n                    if res:\n                        if not csv_writer:\n                            # A file on the disk can be transferred much faster via flask than a string reply\n                            csv_output_filename = f\"report-{self.get('uuid')}.csv\"\n                            f = open(os.path.join(self.data_dir, csv_output_filename), 'w')\n                            # @todo some headers in the future\n                            #fieldnames = ['Epoch seconds', 'Date']\n                            csv_writer = csv.writer(f,\n                                                    delimiter=',',\n                                                    quotechar='\"',\n                                                    quoting=csv.QUOTE_MINIMAL,\n                                                    #fieldnames=fieldnames\n                                                    )\n                            csv_writer.writerow(['Epoch seconds', 'Date'])\n                            # csv_writer.writeheader()\n\n                        date_str = datetime.datetime.fromtimestamp(int(k)).strftime('%Y-%m-%d %H:%M:%S')\n                        for r in res:\n                            row = [k, date_str]\n                            if isinstance(r, str):\n                                row.append(r)\n                            else:\n                                row+=r\n                            csv_writer.writerow(row)\n\n        if f:\n            f.close()\n\n        return csv_output_filename\n\n\n    def has_special_diff_filter_options_set(self):\n\n        # All False - nothing would be done, so act like it's not processable\n        if not self.get('filter_text_added', True) and not self.get('filter_text_replaced', True) and not self.get('filter_text_removed', True):\n            return False\n\n        # Or one is set\n        if not self.get('filter_text_added', True) or not self.get('filter_text_replaced', True) or not self.get('filter_text_removed', True):\n            return True\n\n        # None is set\n        return False\n\n    def save_error_text(self, contents):\n        self.ensure_data_dir_exists()\n        target_path = os.path.join(self.data_dir, \"last-error.txt\")\n        with open(target_path, 'w', encoding='utf-8') as f:\n            f.write(contents)\n\n    def save_xpath_data(self, data, as_error=False):\n        import json\n        import zlib\n\n        if as_error:\n            target_path = os.path.join(str(self.data_dir), \"elements-error.deflate\")\n        else:\n            target_path = os.path.join(str(self.data_dir), \"elements.deflate\")\n\n        self.ensure_data_dir_exists()\n\n        with open(target_path, 'wb') as f:\n            if not isinstance(data, str):\n                f.write(zlib.compress(json.dumps(data).encode()))\n            else:\n                f.write(zlib.compress(data.encode()))\n            f.close()\n\n    # Save as PNG, PNG is larger but better for doing visual diff in the future\n    def save_screenshot(self, screenshot: bytes, as_error=False):\n\n        if as_error:\n            target_path = os.path.join(self.data_dir, \"last-error-screenshot.png\")\n        else:\n            target_path = os.path.join(self.data_dir, \"last-screenshot.png\")\n\n        self.ensure_data_dir_exists()\n\n        with open(target_path, 'wb') as f:\n            f.write(screenshot)\n            f.close()\n\n\n    def get_last_fetched_text_before_filters(self):\n        import brotli\n        filepath = os.path.join(self.data_dir, 'last-fetched.br')\n\n        if not os.path.isfile(filepath) or os.path.getsize(filepath) == 0:\n            # If a previous attempt doesnt yet exist, just snarf the previous snapshot instead\n            dates = list(self.history.keys())\n            if len(dates):\n                return self.get_history_snapshot(timestamp=dates[-1])\n            else:\n                return ''\n\n        with open(filepath, 'rb') as f:\n            return(brotli.decompress(f.read()).decode('utf-8'))\n\n    def save_last_text_fetched_before_filters(self, contents):\n        import brotli\n        filepath = os.path.join(self.data_dir, 'last-fetched.br')\n        _brotli_save(contents, filepath, mode=brotli.MODE_TEXT, fallback_uncompressed=False)\n\n    def save_last_fetched_html(self, timestamp, contents):\n        self.ensure_data_dir_exists()\n        snapshot_fname = f\"{timestamp}.html.br\"\n        filepath = os.path.join(self.data_dir, snapshot_fname)\n        _brotli_save(contents, filepath, mode=None, fallback_uncompressed=True)\n        self._prune_last_fetched_html_snapshots()\n\n    def get_fetched_html(self, timestamp):\n        import brotli\n\n        snapshot_fname = f\"{timestamp}.html.br\"\n        filepath = os.path.join(self.data_dir, snapshot_fname)\n        if os.path.isfile(filepath):\n            with open(filepath, 'rb') as f:\n                return (brotli.decompress(f.read()).decode('utf-8'))\n\n        return False\n\n\n    def _prune_last_fetched_html_snapshots(self):\n\n        dates = list(self.history.keys())\n        dates.reverse()\n\n        for index, timestamp in enumerate(dates):\n            snapshot_fname = f\"{timestamp}.html.br\"\n            filepath = os.path.join(self.data_dir, snapshot_fname)\n\n            # Keep only the first 2\n            if index > 1 and os.path.isfile(filepath):\n                os.remove(filepath)\n\n\n    @property\n    def get_browsersteps_available_screenshots(self):\n        \"For knowing which screenshots are available to show the user in BrowserSteps UI\"\n        available = []\n        for f in Path(self.data_dir).glob('step_before-*.jpeg'):\n            step_n=re.search(r'step_before-(\\d+)', f.name)\n            if step_n:\n                available.append(step_n.group(1))\n        return available\n\n    def compile_error_texts(self, has_proxies=None):\n        \"\"\"Compile error texts for this watch.\n        Accepts has_proxies parameter to ensure it works even outside app context\"\"\"\n        from flask import url_for, has_request_context\n        from markupsafe import Markup\n\n        output = []  # Initialize as list since we're using append\n        last_error = self.get('last_error','')\n\n        has_app_context = has_request_context()\n\n        # has app+request context, we can use url_for()\n        if has_app_context:\n            if last_error:\n                last_error = safe_jinja.render_fully_escaped(last_error)\n                if '403' in last_error:\n                    if has_proxies:\n                        output.append(str(Markup(f\"{last_error} - <a href=\\\"{url_for('settings.settings_page', uuid=self.get('uuid'))}\\\">Try other proxies/location</a>&nbsp;'\")))\n                    else:\n                        output.append(str(Markup(f\"{last_error} - <a href=\\\"{url_for('settings.settings_page', uuid=self.get('uuid'))}\\\">Try adding external proxies/locations</a>&nbsp;'\")))\n                else:\n                    output.append(str(Markup(last_error)))\n\n            if self.get('last_notification_error'):\n                txt = safe_jinja.render_fully_escaped(self.get('last_notification_error'))\n                result = f'<div class=\"notification-error\"><a href=\"{url_for(\"settings.notification_logs\")}\">{txt}</a></div>'\n                output.append(result)\n\n        else:\n            # Lo_Fi version - no app context, cant rely on Jinja2 Markup\n            if last_error:\n                output.append(safe_jinja.render_fully_escaped(last_error))\n            if self.get('last_notification_error'):\n                output.append(safe_jinja.render_fully_escaped(self.get('last_notification_error')))\n\n        res = \"\\n\".join(output)\n        return res\n\n"
  },
  {
    "path": "changedetectionio/model/__init__.py",
    "content": "import os\nimport uuid\n\nfrom changedetectionio import strtobool\nfrom .persistence import EntityPersistenceMixin, _determine_entity_type\n\n__all__ = ['EntityPersistenceMixin', 'watch_base']\n\nfrom ..browser_steps.browser_steps import browser_steps_get_valid_steps\n\nUSE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH = 'System default'\nCONDITIONS_MATCH_LOGIC_DEFAULT = 'ALL'\n\n\nclass watch_base(dict):\n    \"\"\"\n    Base watch domain model (inherits from dict for backward compatibility).\n\n    WARNING: This class inherits from dict, which violates proper encapsulation.\n    Dict inheritance is legacy technical debt that should be refactored to a proper\n    domain model (e.g., Pydantic BaseModel) for better type safety and validation.\n\n    TODO: Migrate to Pydantic BaseModel for:\n          - Type safety and IDE autocomplete\n          - Automatic validation\n          - Clear separation between domain model and serialization\n          - Database backend abstraction (file → postgres → mongodb)\n          - Configuration override chain resolution (Watch → Tag → Global)\n          - Immutability options\n          - Better testing\n          - USE https://docs.pydantic.dev/latest/integrations/datamodel_code_generator TO BUILD THE MODEL FROM THE API-SPEC!!!\n\n    CHAIN RESOLUTION ARCHITECTURE:\n        The dream is a 3-level override hierarchy:\n            Watch settings → Tag/Group settings → Global settings\n\n        Current implementation: MANUAL resolution scattered across codebase\n        - Processors manually check watch.get('field')\n        - Loop through tags to find overrides_watch=True\n        - Fall back to datastore['settings']['application']['field']\n\n        Pydantic implementation: AUTOMATIC resolution via @computed_field\n        - Single source of truth for each setting's resolution logic\n        - Type-safe, testable, self-documenting\n        - Example: watch.resolved_fetch_backend (instead of nested dict navigation)\n\n        See: Watch.py model docstring for detailed Pydantic architecture plan\n        See: Tag.py model docstring for tag override explanation\n        See: processors/restock_diff/processor.py:184-192 for current manual example\n\n    Core Fields:\n        uuid (str): Unique identifier for this watch (auto-generated)\n        url (str): Target URL to monitor for changes\n        title (str|None): Custom display name (overrides page_title if set)\n        page_title (str|None): Title extracted from <title> tag of monitored page\n        tags (List[str]): List of tag UUIDs for categorization\n        tag (str): DEPRECATED - Old single-tag system, use tags instead\n\n    Check Configuration:\n        processor (str): Processor type ('text_json_diff', 'restock_diff', etc.)\n        fetch_backend (str): Fetcher to use ('system', 'html_requests', 'playwright', etc.)\n        method (str): HTTP method ('GET', 'POST', etc.)\n        headers (dict): Custom HTTP headers to send\n        proxy (str|None): Preferred proxy server\n        paused (bool): Whether change detection is paused\n\n    Scheduling:\n        time_between_check (dict): Check interval {'weeks': int, 'days': int, 'hours': int, 'minutes': int, 'seconds': int}\n        time_between_check_use_default (bool): Use global default interval if True\n        time_schedule_limit (dict): Weekly schedule limiting when checks can run\n            Structure: {\n                'enabled': bool,\n                'monday/tuesday/.../sunday': {\n                    'enabled': bool,\n                    'start_time': str ('HH:MM'),\n                    'duration': {'hours': str, 'minutes': str}\n                }\n            }\n\n    Content Filtering:\n        include_filters (List[str]): CSS/XPath selectors to extract content\n        subtractive_selectors (List[str]): Selectors to remove from content\n        ignore_text (List[str]): Text patterns to ignore in change detection\n        trigger_text (List[str]): Text/regex that must be present to trigger change\n        text_should_not_be_present (List[str]): Text that should NOT be present\n        extract_text (List[str]): Regex patterns to extract specific text after filtering\n\n    Text Processing:\n        trim_text_whitespace (bool): Strip leading/trailing whitespace\n        sort_text_alphabetically (bool): Sort lines alphabetically before comparison\n        remove_duplicate_lines (bool): Remove duplicate lines\n        check_unique_lines (bool): Compare against all history for unique lines\n        strip_ignored_lines (bool|None): Remove lines matching ignore patterns\n\n    Change Detection Filters:\n        filter_text_added (bool): Include added text in change detection\n        filter_text_removed (bool): Include removed text in change detection\n        filter_text_replaced (bool): Include replaced text in change detection\n\n    Browser Automation:\n        browser_steps (List[dict]): Browser automation steps for JS-heavy sites\n        browser_steps_last_error_step (int|None): Last step that caused error\n        webdriver_delay (int|None): Seconds to wait after page load\n        webdriver_js_execute_code (str|None): JavaScript to execute before extraction\n\n    Restock Detection:\n        in_stock_only (bool): Only trigger on in-stock transitions\n        follow_price_changes (bool): Monitor price changes\n        has_ldjson_price_data (bool|None): Whether page has LD-JSON price data\n        track_ldjson_price_data (str|None): Track LD-JSON price data ('ACCEPT', 'REJECT', None)\n        price_change_threshold_percent (float|None): Minimum price change % to trigger\n\n    Notifications:\n        notification_urls (List[str]): Apprise URLs for notifications\n        notification_title (str|None): Custom notification title template\n        notification_body (str|None): Custom notification body template\n        notification_format (str): Notification format (e.g., 'System default', 'Text', 'HTML')\n        notification_muted (bool): Disable notifications for this watch\n        notification_screenshot (bool): Include screenshot in notifications\n        notification_alert_count (int): Number of notifications sent\n        last_notification_error (str|None): Last notification error message\n        body (str|None): DEPRECATED? Legacy notification body field\n        filter_failure_notification_send (bool): Send notification on filter failures\n\n    History & State:\n        date_created (int|None): Unix timestamp of watch creation\n        last_checked (int): Unix timestamp of last check\n        last_viewed (int): History snapshot key of last user view\n        last_error (str|bool): Last error message or False if no error\n        check_count (int): Total number of checks performed\n        fetch_time (float): Duration of last fetch in seconds\n        consecutive_filter_failures (int): Counter for consecutive filter match failures\n        previous_md5 (str|bool): MD5 hash of previous content\n        history_snapshot_max_length (int|None): Max history snapshots to keep (None = use global)\n\n    Conditions:\n        conditions (dict): Custom conditions for change detection logic\n        conditions_match_logic (str): Logic operator ('ALL', 'ANY') for conditions\n\n    Metadata:\n        content-type (str|None): Content-Type from last fetch\n        remote_server_reply (str|None): Server header from last response\n        ignore_status_codes (List[int]|None): HTTP status codes to ignore\n        use_page_title_in_list (bool|None): Display page title in watch list (None = use system default)\n\n    Instance Attributes (not serialized):\n        __datastore: Reference to parent DataStore (set externally after creation)\n        data_dir: Filesystem path for this watch's data directory\n\n    Notes:\n        - Many fields default to None to distinguish \"not set\" from \"set to default\"\n        - When field is None, system-level defaults are used\n        - Processor-specific configs (e.g., processor_config_*) are NOT stored in watch.json\n          They are stored in separate {processor_name}.json files\n        - This class is used for both Watch and Tag objects (tags reuse the structure)\n    \"\"\"\n\n    def __init__(self, *arg, **kw):\n        # Store datastore reference (common to Watch and Tag)\n        # Use single underscore to avoid name mangling issues in subclasses\n        self._datastore = kw.get('__datastore')\n        if kw.get('__datastore'):\n            del kw['__datastore']\n\n        # Store datastore_path (common to Watch and Tag)\n        self._datastore_path = kw.get('datastore_path')\n        if kw.get('datastore_path'):\n            del kw['datastore_path']\n\n        # IMPORTANT: Don't initialize __watch_was_edited yet!\n        # We'll initialize it AFTER the initial update() call below\n        # This prevents marking the watch as edited during initialization\n\n        self.update({\n            # Custom notification content\n            # Re #110, so then if this is set to None, we know to use the default value instead\n            # Requires setting to None on submit if it's the same as the default\n            # Should be all None by default, so we use the system default in this case.\n            'body': None,\n            'browser_steps': [],\n            'browser_steps_last_error_step': None,\n            'conditions' : [],\n            'conditions_match_logic': CONDITIONS_MATCH_LOGIC_DEFAULT,\n            'check_count': 0,\n            'check_unique_lines': False,  # On change-detected, compare against all history if its something new\n            'consecutive_filter_failures': 0,  # Every time the CSS/xPath filter cannot be located, reset when all is fine.\n            'content-type': None,\n            'date_created': None,\n            'extract_text': [],  # Extract text by regex after filters\n            'fetch_backend': 'system',  # plaintext, playwright etc\n            'fetch_time': 0.0,\n            'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),\n            'filter_text_added': True,\n            'filter_text_removed': True,\n            'filter_text_replaced': True,\n            'follow_price_changes': True,\n            'has_ldjson_price_data': None,\n            'history_snapshot_max_length': None,\n            'headers': {},  # Extra headers to send\n            'ignore_text': [],  # List of text to ignore when calculating the comparison checksum\n            'ignore_status_codes': None,\n            'in_stock_only': True,  # Only trigger change on going to instock from out-of-stock\n            'include_filters': [],\n            'last_checked': 0,\n            'last_error': False,\n            'last_notification_error': None,\n            'last_viewed': 0,  # history key value of the last viewed via the [diff] link\n            'method': 'GET',\n            'notification_alert_count': 0,\n            'notification_body': None,\n            'notification_format': USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH,\n            'notification_muted': False,\n            'notification_screenshot': False,  # Include the latest screenshot if available and supported by the apprise URL\n            'notification_title': None,\n            'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise)\n            'page_title': None, # <title> from the page\n            'paused': False,\n            'previous_md5': False,\n            'processor': 'text_json_diff',  # could be restock_diff or others from .processors\n            'price_change_threshold_percent': None,\n            'proxy': None,  # Preferred proxy connection\n            'remote_server_reply': None,  # From 'server' reply header\n            'sort_text_alphabetically': False,\n            'strip_ignored_lines': None,\n            'subtractive_selectors': [],\n            'tag': '',  # Old system of text name for a tag, to be removed\n            'tags': [],  # list of UUIDs to App.Tags\n            'text_should_not_be_present': [],  # Text that should not present\n            'time_between_check': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None},\n            'time_between_check_use_default': True,\n            \"time_schedule_limit\": {\n                \"enabled\": False,\n                \"monday\": {\n                    \"enabled\": True,\n                    \"start_time\": \"00:00\",\n                    \"duration\": {\n                        \"hours\": \"24\",\n                        \"minutes\": \"00\"\n                    }\n                },\n                \"tuesday\": {\n                    \"enabled\": True,\n                    \"start_time\": \"00:00\",\n                    \"duration\": {\n                        \"hours\": \"24\",\n                        \"minutes\": \"00\"\n                    }\n                },\n                \"wednesday\": {\n                    \"enabled\": True,\n                    \"start_time\": \"00:00\",\n                    \"duration\": {\n                        \"hours\": \"24\",\n                        \"minutes\": \"00\"\n                    }\n                },\n                \"thursday\": {\n                    \"enabled\": True,\n                    \"start_time\": \"00:00\",\n                    \"duration\": {\n                        \"hours\": \"24\",\n                        \"minutes\": \"00\"\n                    }\n                },\n                \"friday\": {\n                    \"enabled\": True,\n                    \"start_time\": \"00:00\",\n                    \"duration\": {\n                        \"hours\": \"24\",\n                        \"minutes\": \"00\"\n                    }\n                },\n                \"saturday\": {\n                    \"enabled\": True,\n                    \"start_time\": \"00:00\",\n                    \"duration\": {\n                        \"hours\": \"24\",\n                        \"minutes\": \"00\"\n                    }\n                },\n                \"sunday\": {\n                    \"enabled\": True,\n                    \"start_time\": \"00:00\",\n                    \"duration\": {\n                        \"hours\": \"24\",\n                        \"minutes\": \"00\"\n                    }\n                },\n            },\n            'title': None, # An arbitrary field that overrides 'page_title'\n            'track_ldjson_price_data': None,\n            'trim_text_whitespace': False,\n            'remove_duplicate_lines': False,\n            'trigger_text': [],  # List of text or regex to wait for until a change is detected\n            'url': '',\n            'use_page_title_in_list': None, # None = use system settings\n            'uuid': str(uuid.uuid4()),\n            'webdriver_delay': None,\n            'webdriver_js_execute_code': None,  # Run before change-detection\n        })\n\n        super(watch_base, self).__init__(*arg, **kw)\n\n        # Check if we're being initialized from an existing watch object\n        # that has was_edited=True, so we can preserve the flag\n        preserve_edited_flag = False\n        if self.get('default'):\n            # When creating a new watch object from an existing one (e.g., changing processor),\n            # preserve the was_edited flag if it was True\n            default_watch = self.get('default')\n            if hasattr(default_watch, 'was_edited') and default_watch.was_edited:\n                preserve_edited_flag = True\n            del self['default']\n\n        # NOW initialize the edited flag after all initial setup is complete\n        # This ensures initialization doesn't trigger the edited flag\n        # But preserve it if the source watch had it set to True\n        self.__watch_was_edited = preserve_edited_flag\n\n    def _mark_field_as_edited(self, key):\n        \"\"\"\n        Helper to mark a field as edited if it's writable.\n\n        Internal method used by __setitem__, update(), pop(), etc.\n        \"\"\"\n        # Don't track edits during initial load or if already edited\n        if not hasattr(self, '_watch_base__watch_was_edited'):\n            return\n        if self.__watch_was_edited:\n            return  # Already marked as edited\n\n        # Import from shared schema utilities (no circular dependency)\n        from .schema_utils import get_readonly_watch_fields\n        readonly_fields = get_readonly_watch_fields()\n\n        # Additional system-managed fields not in OpenAPI spec (yet)\n        # These are set by processors/workers and should not trigger edited flag\n        additional_system_fields = {\n            'last_check_status',  # Set by processors\n            'restock',  # Set by restock processor\n            'last_viewed',  # Set by mark_all_viewed endpoint\n        }\n\n        # Only mark as edited if this is a user-writable field\n        if key not in readonly_fields and key not in additional_system_fields:\n            self.__watch_was_edited = True\n\n    def __setitem__(self, key, value):\n        \"\"\"\n        Override dict.__setitem__ to track when writable watch fields are modified.\n\n        This enables skipping reprocessing when:\n        1. HTML content is unchanged (checksumFromPreviousCheckWasTheSame)\n        2. AND watch configuration was not edited\n\n        Only sets the edited flag when field is NOT in readonly_fields (from OpenAPI spec).\n        \"\"\"\n        # Set the value first (always)\n        super().__setitem__(key, value)\n        # Mark as edited if writable field\n        self._mark_field_as_edited(key)\n\n    def __delitem__(self, key):\n        \"\"\"Override dict.__delitem__ to track deletions of writable fields.\"\"\"\n        super().__delitem__(key)\n        self._mark_field_as_edited(key)\n\n    def update(self, *args, **kwargs):\n\n        if args and args[0].get('browser_steps'):\n            args[0]['browser_steps'] = browser_steps_get_valid_steps(args[0].get('browser_steps'))\n\n        \"\"\"Override dict.update() to track modifications to writable fields.\"\"\"\n        # Call parent update first\n        super().update(*args, **kwargs)\n\n        # Mark as edited for any writable fields that were updated\n        # Handle both update(dict) and update(key=value) forms\n        if args:\n            for key in args[0].keys():\n                self._mark_field_as_edited(key)\n        for key in kwargs.keys():\n            self._mark_field_as_edited(key)\n\n\n    def pop(self, key, *args):\n        \"\"\"Override dict.pop() to track removal of writable fields.\"\"\"\n        result = super().pop(key, *args)\n        self._mark_field_as_edited(key)\n        return result\n\n    def setdefault(self, key, default=None):\n        \"\"\"Override dict.setdefault() to track modifications to writable fields.\"\"\"\n        # Only marks as edited if key didn't exist (i.e., a new value was set)\n        existed = key in self\n        result = super().setdefault(key, default)\n        if not existed:\n            self._mark_field_as_edited(key)\n        return result\n\n    @property\n    def was_edited(self):\n        \"\"\"\n        Check if watch configuration was edited since last processing.\n\n        Returns:\n            bool: True if writable fields were modified, False otherwise\n        \"\"\"\n        return getattr(self, '_watch_base__watch_was_edited', False)\n\n    def reset_watch_edited_flag(self):\n        \"\"\"\n        Reset the watch edited flag after successful processing.\n\n        Call this after processing completes to allow future content-only change detection.\n        \"\"\"\n        self.__watch_was_edited = False\n\n    @classmethod\n    def get_property_names(cls):\n        \"\"\"\n        Get all @property attribute names from this model class using introspection.\n\n        This discovers computed/derived properties that are not stored in the datastore.\n        These properties should be filtered out during PUT/POST requests.\n\n        Returns:\n            frozenset: Immutable set of @property attribute names from the model class\n        \"\"\"\n        import functools\n\n        # Create a cached version if it doesn't exist\n        if not hasattr(cls, '_cached_get_property_names'):\n            @functools.cache\n            def _get_props():\n                properties = set()\n                # Use introspection to find all @property attributes\n                for name in dir(cls):\n                    # Skip private/magic attributes\n                    if name.startswith('_'):\n                        continue\n                    try:\n                        attr = getattr(cls, name)\n                        # Check if it's a property descriptor\n                        if isinstance(attr, property):\n                            properties.add(name)\n                    except (AttributeError, TypeError):\n                        continue\n                return frozenset(properties)\n\n            cls._cached_get_property_names = _get_props\n\n        return cls._cached_get_property_names()\n\n    def __deepcopy__(self, memo):\n        \"\"\"\n        Custom deepcopy for all watch_base subclasses (Watch, Tag, etc.).\n\n        CRITICAL FIX: Prevents copying large reference objects like __datastore\n        which would cause exponential memory growth when Watch objects are deepcopied.\n\n        This is called by:\n        - api/Watch.py:76 (API endpoint)\n        - api/Tags.py:28 (Tags API)\n        - processors/base.py:26 (EVERY processor run)\n        - store/__init__.py:544 (clone watch)\n        - And other locations\n        \"\"\"\n        from copy import deepcopy\n\n        # Create new instance without calling __init__\n        cls = self.__class__\n        new_obj = cls.__new__(cls)\n        memo[id(self)] = new_obj\n\n        # Copy the dict data (all the settings)\n        for key, value in self.items():\n            new_obj[key] = deepcopy(value, memo)\n\n        # Copy instance attributes dynamically\n        # This handles Watch-specific attrs (like __datastore) and any future subclass attrs\n        for attr_name in dir(self):\n            # Skip methods, special attrs, and dict keys\n            if attr_name.startswith('_') and not attr_name.startswith('__'):\n                # This catches _model__datastore, _model__history_n, etc.\n                try:\n                    attr_value = getattr(self, attr_name)\n\n                    # Special handling: Share references to large objects instead of copying\n                    # Examples: _datastore, __datastore, __app_reference, __global_settings, etc.\n                    if (attr_name == '_datastore' or\n                        attr_name.endswith('__datastore') or\n                        attr_name.endswith('__app')):\n                        # Share the reference (don't copy!) to prevent memory leaks\n                        setattr(new_obj, attr_name, attr_value)\n                    # Skip cache attributes - let them regenerate on demand\n                    elif 'cache' in attr_name.lower():\n                        pass  # Don't copy caches\n                    # Copy regular instance attributes\n                    elif not callable(attr_value):\n                        setattr(new_obj, attr_name, attr_value)\n                except AttributeError:\n                    pass  # Attribute doesn't exist in this instance\n\n        return new_obj\n\n    def __getstate__(self):\n        \"\"\"\n        Custom pickle serialization for all watch_base subclasses.\n\n        Excludes large reference objects (like __datastore) from serialization.\n        \"\"\"\n        # Get the dict data\n        state = dict(self)\n\n        # Collect instance attributes (excluding methods and large references)\n        instance_attrs = {}\n        for attr_name in dir(self):\n            if attr_name.startswith('_') and not attr_name.startswith('__'):\n                try:\n                    attr_value = getattr(self, attr_name)\n                    # Exclude large reference objects and caches from serialization\n                    if not (attr_name == '_datastore' or\n                           attr_name.endswith('__datastore') or\n                           attr_name.endswith('__app') or\n                           'cache' in attr_name.lower() or\n                           callable(attr_value)):\n                        instance_attrs[attr_name] = attr_value\n                except AttributeError:\n                    pass\n\n        if instance_attrs:\n            state['__instance_metadata__'] = instance_attrs\n\n        return state\n\n    def __setstate__(self, state):\n        \"\"\"\n        Custom pickle deserialization for all watch_base subclasses.\n\n        WARNING: Large reference objects (like __datastore) are NOT restored!\n        Caller must restore these references after unpickling if needed.\n        \"\"\"\n        # Extract metadata\n        metadata = state.pop('__instance_metadata__', {})\n\n        # Restore dict data\n        self.update(state)\n\n        # Restore instance attributes\n        for attr_name, attr_value in metadata.items():\n            setattr(self, attr_name, attr_value)\n\n    @property\n    def data_dir(self):\n        \"\"\"\n        The base directory for this watch/tag data (property, computed from UUID).\n\n        Common property for both Watch and Tag objects.\n        Returns path like: /datastore/{uuid}/\n        \"\"\"\n        return os.path.join(self._datastore_path, self['uuid']) if self._datastore_path else None\n\n    def ensure_data_dir_exists(self):\n        \"\"\"\n        Create the data directory if it doesn't exist.\n\n        Common method for both Watch and Tag objects.\n        \"\"\"\n        from loguru import logger\n        if not os.path.isdir(self.data_dir):\n            logger.debug(f\"> Creating data dir {self.data_dir}\")\n            os.mkdir(self.data_dir)\n\n    def get_global_setting(self, *path):\n        \"\"\"\n        Get a setting from the global datastore configuration.\n\n        Args:\n            *path: Path to the setting (e.g., 'application', 'history_snapshot_max_length')\n\n        Returns:\n            The setting value, or None if not found\n\n        Example:\n            maxlen = self.get_global_setting('application', 'history_snapshot_max_length')\n        \"\"\"\n        if not self._datastore:\n            return None\n\n        try:\n            value = self._datastore['settings']\n            for key in path:\n                value = value[key]\n            return value\n        except (KeyError, TypeError):\n            return None\n\n    def _get_commit_data(self):\n        \"\"\"\n        Prepare data for commit (can be overridden by subclasses).\n\n        Returns:\n            dict: Data to serialize (filtered as needed by subclass)\n        \"\"\"\n        import copy\n\n        # Acquire datastore lock to prevent concurrent modifications during copy\n        lock = self._datastore.lock if self._datastore and hasattr(self._datastore, 'lock') else None\n\n        if lock:\n            with lock:\n                snapshot = dict(self)\n        else:\n            snapshot = dict(self)\n\n        # Deep copy snapshot (slower, but done outside lock to minimize contention)\n        # Subclasses can override to filter keys (e.g., Watch excludes processor_config_*)\n        return {k: copy.deepcopy(v) for k, v in snapshot.items()}\n\n    def _save_to_disk(self, data_dict, uuid):\n        \"\"\"\n        Save data to disk (must be implemented by subclasses).\n\n        Args:\n            data_dict: Dictionary to save\n            uuid: UUID for logging\n\n        Raises:\n            NotImplementedError: If subclass doesn't implement\n        \"\"\"\n        raise NotImplementedError(\"Subclass must implement _save_to_disk()\")\n\n    def commit(self):\n        \"\"\"\n        Save this watch/tag immediately to disk using atomic write.\n\n        Common commit logic for Watch and Tag objects.\n        Subclasses override _get_commit_data() and _save_to_disk() for specifics.\n\n        Fire-and-forget: Logs errors but does not raise exceptions.\n        Data remains in memory even if save fails, so next commit will retry.\n        \"\"\"\n        from loguru import logger\n\n        if not self.data_dir:\n            entity_type = self.__class__.__name__\n            logger.error(f\"Cannot commit {entity_type} {self.get('uuid')} without datastore_path\")\n            return\n\n        uuid = self.get('uuid')\n        if not uuid:\n            entity_type = self.__class__.__name__\n            logger.error(f\"Cannot commit {entity_type} without UUID\")\n            return\n\n        # Get data from subclass (may filter keys)\n        try:\n            data_dict = self._get_commit_data()\n        except Exception as e:\n            logger.error(f\"Failed to prepare commit data for {uuid}: {e}\")\n            return\n\n        # Save to disk via subclass implementation\n        try:\n            # Determine entity type from module name (Watch.py -> watch, Tag.py -> tag)\n            entity_type = _determine_entity_type(self.__class__)\n            filename = f\"{entity_type}.json\"\n            self._save_to_disk(data_dict, uuid)\n            logger.debug(f\"Committed {entity_type} {uuid} to {uuid}/{filename}\")\n        except Exception as e:\n            logger.error(f\"Failed to commit {uuid}: {e}\")"
  },
  {
    "path": "changedetectionio/model/persistence.py",
    "content": "\"\"\"\nEntity persistence mixin for Watch and Tag models.\n\nProvides file-based persistence using atomic writes.\n\"\"\"\n\nimport functools\nimport inspect\n\n\n@functools.lru_cache(maxsize=None)\ndef _determine_entity_type(cls):\n    \"\"\"\n    Determine entity type from class hierarchy (cached at class level).\n\n    Args:\n        cls: The class to inspect\n\n    Returns:\n        str: Entity type ('watch', 'tag', etc.)\n\n    Raises:\n        ValueError: If entity type cannot be determined\n    \"\"\"\n    for base_class in inspect.getmro(cls):\n        module_name = base_class.__module__\n        if module_name.startswith('changedetectionio.model.'):\n            # Get last part after dot: \"changedetectionio.model.Watch\" -> \"watch\"\n            return module_name.split('.')[-1].lower()\n\n    raise ValueError(\n        f\"Cannot determine entity type for {cls.__module__}.{cls.__name__}. \"\n        f\"Entity must inherit from a class in changedetectionio.model (Watch or Tag).\"\n    )\n\n\nclass EntityPersistenceMixin:\n    \"\"\"\n    Mixin providing file persistence for watch_base subclasses (Watch, Tag, etc.).\n\n    This mixin provides the _save_to_disk() method required by watch_base.commit().\n    It automatically determines the correct filename and size limits based on class hierarchy.\n\n    Usage:\n        class model(EntityPersistenceMixin, watch_base):  # in Watch.py\n            pass\n\n        class model(EntityPersistenceMixin, watch_base):  # in Tag.py\n            pass\n    \"\"\"\n\n    def _save_to_disk(self, data_dict, uuid):\n        \"\"\"\n        Save entity to disk using atomic write.\n\n        Implements the abstract method required by watch_base.commit().\n        Automatically determines filename and size limits from class hierarchy.\n\n        Args:\n            data_dict: Dictionary to save\n            uuid: UUID for logging\n\n        Raises:\n            ValueError: If entity type cannot be determined from class hierarchy\n        \"\"\"\n        # Import here to avoid circular dependency\n        from changedetectionio.store.file_saving_datastore import save_entity_atomic\n\n        # Determine entity type (cached at class level, not instance level)\n        entity_type = _determine_entity_type(self.__class__)\n\n        # Set filename and size limits based on entity type\n        filename = f'{entity_type}.json'\n        max_size_mb = 10 if entity_type == 'watch' else 1\n\n        # Save using generic function\n        save_entity_atomic(\n            self.data_dir,\n            uuid,\n            data_dict,\n            filename=filename,\n            entity_type=entity_type,\n            max_size_mb=max_size_mb\n        )\n"
  },
  {
    "path": "changedetectionio/model/schema_utils.py",
    "content": "\"\"\"\nSchema utilities for Watch and Tag models.\n\nProvides functions to extract readonly fields and properties from OpenAPI spec.\nShared by both the model layer and API layer to avoid circular dependencies.\n\"\"\"\n\nimport functools\n\n\n@functools.cache\ndef get_openapi_schema_dict():\n    \"\"\"\n    Get the raw OpenAPI spec dictionary for schema access.\n\n    Returns the YAML dict directly (not the OpenAPI object).\n    \"\"\"\n    import os\n    import yaml\n\n    spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')\n    if not os.path.exists(spec_path):\n        spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')\n\n    with open(spec_path, 'r', encoding='utf-8') as f:\n        return yaml.safe_load(f)\n\n\n@functools.cache\ndef _resolve_readonly_fields(schema_name):\n    \"\"\"\n    Generic helper to resolve readOnly fields, including allOf inheritance.\n\n    Args:\n        schema_name: Name of the schema (e.g., 'Watch', 'Tag')\n\n    Returns:\n        frozenset: All readOnly field names including inherited ones\n    \"\"\"\n    spec_dict = get_openapi_schema_dict()\n    schema = spec_dict['components']['schemas'].get(schema_name, {})\n\n    readonly_fields = set()\n\n    # Handle allOf (schema inheritance)\n    if 'allOf' in schema:\n        for item in schema['allOf']:\n            # Resolve $ref to parent schema\n            if '$ref' in item:\n                ref_path = item['$ref'].split('/')[-1]\n                ref_schema = spec_dict['components']['schemas'].get(ref_path, {})\n                if 'properties' in ref_schema:\n                    for field_name, field_def in ref_schema['properties'].items():\n                        if field_def.get('readOnly') is True:\n                            readonly_fields.add(field_name)\n            # Check schema-specific properties\n            if 'properties' in item:\n                for field_name, field_def in item['properties'].items():\n                    if field_def.get('readOnly') is True:\n                        readonly_fields.add(field_name)\n    else:\n        # Direct properties (no inheritance)\n        if 'properties' in schema:\n            for field_name, field_def in schema['properties'].items():\n                if field_def.get('readOnly') is True:\n                    readonly_fields.add(field_name)\n\n    return frozenset(readonly_fields)\n\n\n@functools.cache\ndef get_readonly_watch_fields():\n    \"\"\"\n    Extract readOnly field names from Watch schema in OpenAPI spec.\n\n    Returns readOnly fields from WatchBase (uuid, date_created) + Watch-specific readOnly fields.\n\n    Used by:\n    - model/watch_base.py: Track when writable fields are edited\n    - api/Watch.py: Filter readonly fields from PUT requests\n    \"\"\"\n    return _resolve_readonly_fields('Watch')\n\n\n@functools.cache\ndef get_readonly_tag_fields():\n    \"\"\"\n    Extract readOnly field names from Tag schema in OpenAPI spec.\n\n    Returns readOnly fields from WatchBase (uuid, date_created) + Tag-specific readOnly fields.\n    \"\"\"\n    return _resolve_readonly_fields('Tag')\n"
  },
  {
    "path": "changedetectionio/notification/__init__.py",
    "content": "from changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n\ndefault_notification_format = 'htmlcolor'\ndefault_notification_body = '{{watch_url}} had a change.\\n---\\n{{diff}}\\n---\\n'\ndefault_notification_title = 'ChangeDetection.io Notification - {{watch_url}}'\n\n# The values (markdown etc) are from apprise NotifyFormat,\n# But to avoid importing the whole heavy module just use the same strings here.\nvalid_notification_formats = {\n    'text': 'Plain Text',\n    'html': 'HTML',\n    'htmlcolor': 'HTML Color',\n    'markdown': 'Markdown to HTML',\n    # Used only for editing a watch (not for global)\n    USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH: USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n}\n"
  },
  {
    "path": "changedetectionio/notification/apprise_plugin/__init__.py",
    "content": ""
  },
  {
    "path": "changedetectionio/notification/apprise_plugin/assets.py",
    "content": "from apprise import AppriseAsset\n\n# Refer to:\n# https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object\n\nAPPRISE_APP_ID = \"changedetection.io\"\nAPPRISE_APP_DESC = \"ChangeDetection.io best and simplest website monitoring and change detection\"\nAPPRISE_APP_URL = \"https://changedetection.io\"\nAPPRISE_AVATAR_URL = \"https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png\"\n\napprise_asset = AppriseAsset(\n    app_id=APPRISE_APP_ID,\n    app_desc=APPRISE_APP_DESC,\n    app_url=APPRISE_APP_URL,\n    image_url_logo=APPRISE_AVATAR_URL,\n)\n"
  },
  {
    "path": "changedetectionio/notification/apprise_plugin/custom_handlers.py",
    "content": "\"\"\"\nCustom Apprise HTTP Handlers with format= Parameter Support\n\nIMPORTANT: This module works around a limitation in Apprise's @notify decorator.\n\nTHE PROBLEM:\n-------------\nWhen using Apprise's @notify decorator to create custom notification handlers, the\ndecorator creates a CustomNotifyPlugin that uses parse_url(..., simple=True) to parse\nURLs. This simple parsing mode does NOT extract the format= query parameter from the URL\nand set it as a top-level parameter that NotifyBase.__init__ can use to set notify_format.\n\nAs a result:\n1. URL: post://example.com/webhook?format=html\n2. Apprise parses this and sees format=html in qsd (query string dictionary)\n3. But it does NOT extract it and pass it to NotifyBase.__init__\n4. NotifyBase defaults to notify_format=TEXT\n5. When you call apobj.notify(body=\"<html>...\", body_format=\"html\"):\n   - Apprise sees: input format = html, output format (notify_format) = text\n   - Apprise calls convert_between(\"html\", \"text\", body)\n   - This strips all HTML tags, leaving only plain text\n6. Your custom handler receives stripped plain text instead of HTML\n\nTHE SOLUTION:\n-------------\nInstead of using the @notify decorator directly, we:\n1. Manually register custom plugins using plugins.N_MGR.add()\n2. Create a CustomHTTPHandler class that extends CustomNotifyPlugin\n3. Override __init__ to extract format= from qsd and set it as kwargs['format']\n4. Call NotifyBase.__init__ which properly sets notify_format from kwargs['format']\n5. Set up _default_args like CustomNotifyPlugin does for compatibility\n\nThis ensures that when format=html is in the URL:\n- notify_format is set to HTML\n- Apprise sees: input format = html, output format = html\n- No conversion happens (convert_between returns content unchanged)\n- Your custom handler receives the original HTML intact\n\nTESTING:\n--------\nTo verify this works:\n>>> apobj = apprise.Apprise()\n>>> apobj.add('post://localhost:5005/test?format=html')\n>>> for server in apobj:\n...     print(server.notify_format)  # Should print: html (not text)\n>>> apobj.notify(body='<span>Test</span>', body_format='html')\n# Your handler should receive '<span>Test</span>' not 'Test'\n\"\"\"\n\nimport json\nimport re\nfrom urllib.parse import unquote_plus\n\nimport requests\nfrom apprise import plugins\nfrom apprise.decorators.base import CustomNotifyPlugin\nfrom apprise.utils.parse import parse_url as apprise_parse_url, url_assembly\nfrom apprise.utils.logic import dict_full_update\nfrom loguru import logger\nfrom requests.structures import CaseInsensitiveDict\n\nSUPPORTED_HTTP_METHODS = {\"get\", \"post\", \"put\", \"delete\", \"patch\", \"head\"}\n\n\ndef notify_supported_methods(func):\n    \"\"\"Register custom HTTP method handlers that properly support format= parameter.\"\"\"\n    for method in SUPPORTED_HTTP_METHODS:\n        _register_http_handler(method, func)\n        _register_http_handler(f\"{method}s\", func)\n    return func\n\n\ndef _register_http_handler(schema, send_func):\n    \"\"\"Register a custom HTTP handler that extracts format= from URL query parameters.\"\"\"\n\n    # Parse base URL\n    base_url = f\"{schema}://\"\n    base_args = apprise_parse_url(base_url, default_schema=schema, verify_host=False, simple=True)\n\n    class CustomHTTPHandler(CustomNotifyPlugin):\n        secure_protocol = schema\n        service_name = f\"Custom HTTP - {schema.upper()}\"\n        _base_args = base_args\n\n        def __init__(self, **kwargs):\n            # Extract format from qsd and set it as a top-level kwarg\n            # This allows NotifyBase.__init__ to properly set notify_format\n            if 'qsd' in kwargs and 'format' in kwargs['qsd']:\n                kwargs['format'] = kwargs['qsd']['format']\n\n            # Call NotifyBase.__init__ (skip CustomNotifyPlugin.__init__)\n            super(CustomNotifyPlugin, self).__init__(**kwargs)\n\n            # Set up _default_args like CustomNotifyPlugin does\n            self._default_args = {}\n            kwargs.pop(\"secure\", None)\n            dict_full_update(self._default_args, self._base_args)\n            dict_full_update(self._default_args, kwargs)\n            self._default_args[\"url\"] = url_assembly(**self._default_args)\n\n        __send = staticmethod(send_func)\n\n        def send(self, body, title=\"\", notify_type=\"info\", *args, **kwargs):\n            \"\"\"Call the custom send function.\"\"\"\n            try:\n                result = self.__send(\n                    body, title, notify_type,\n                    *args,\n                    meta=self._default_args,\n                    **kwargs\n                )\n                return True if result is None else bool(result)\n            except Exception as e:\n                self.logger.warning(f\"Exception in custom HTTP handler: {e}\")\n                return False\n\n    # Register the plugin\n    plugins.N_MGR.add(\n        plugin=CustomHTTPHandler,\n        schemas=schema,\n        send_func=send_func,\n        url=base_url,\n    )\n\n\ndef _get_auth(parsed_url: dict) -> str | tuple[str, str]:\n    user: str | None = parsed_url.get(\"user\")\n    password: str | None = parsed_url.get(\"password\")\n\n    if user is not None and password is not None:\n        return (unquote_plus(user), unquote_plus(password))\n\n    if user is not None:\n        return unquote_plus(user)\n\n    return \"\"\n\n\ndef _get_headers(parsed_url: dict, body: str) -> CaseInsensitiveDict:\n    headers = CaseInsensitiveDict(\n        {unquote_plus(k).title(): unquote_plus(v) for k, v in parsed_url[\"qsd+\"].items()}\n    )\n\n    # If Content-Type is not specified, guess if the body is a valid JSON\n    if headers.get(\"Content-Type\") is None:\n        try:\n            json.loads(body)\n            headers[\"Content-Type\"] = \"application/json; charset=utf-8\"\n        except Exception:\n            pass\n\n    return headers\n\n\ndef _get_params(parsed_url: dict) -> CaseInsensitiveDict:\n    # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation\n    # In Apprise, it relies on prefixing each request arg with \"-\", because it uses say &method=update as a flag for apprise\n    # but here we are making straight requests, so we need todo convert this against apprise's logic\n    params = CaseInsensitiveDict(\n        {\n            unquote_plus(k): unquote_plus(v)\n            for k, v in parsed_url[\"qsd\"].items()\n            if k.strip(\"-\") not in parsed_url[\"qsd-\"]\n            and k.strip(\"+\") not in parsed_url[\"qsd+\"]\n        }\n    )\n\n    return params\n\n\n@notify_supported_methods\ndef apprise_http_custom_handler(\n    body: str,\n    title: str,\n    notify_type: str,\n    meta: dict,\n    body_format: str = None,\n    *args,\n    **kwargs,\n) -> bool:\n\n\n    url: str = meta.get(\"url\")\n    schema: str = meta.get(\"schema\")\n    method: str = re.sub(r\"s$\", \"\", schema).upper()\n\n    # Convert /foobar?+some-header=hello to proper header dictionary\n    parsed_url: dict[str, str | dict | None] | None = apprise_parse_url(url)\n    if parsed_url is None:\n        return False\n\n    auth = _get_auth(parsed_url=parsed_url)\n    headers = _get_headers(parsed_url=parsed_url, body=body)\n    params = _get_params(parsed_url=parsed_url)\n\n    url = re.sub(rf\"^{schema}\", \"https\" if schema.endswith(\"s\") else \"http\", parsed_url.get(\"url\"))\n\n    response = requests.request(\n        method=method,\n        url=url,\n        auth=auth,\n        headers=headers,\n        params=params,\n        data=body.encode(\"utf-8\") if isinstance(body, str) else body,\n    )\n\n    response.raise_for_status()\n\n    logger.info(f\"Successfully sent custom notification to {url}\")\n    return True\n"
  },
  {
    "path": "changedetectionio/notification/apprise_plugin/discord.py",
    "content": "\"\"\"\nCustom Discord plugin for changedetection.io\nExtends Apprise's Discord plugin to support custom colored embeds for removed/added content\n\"\"\"\nfrom apprise.plugins.discord import NotifyDiscord\nfrom apprise.decorators import notify\nfrom apprise.common import NotifyFormat\nfrom loguru import logger\n\n# Import placeholders from changedetection's diff module\nfrom ...diff import (\n    REMOVED_PLACEMARKER_OPEN,\n    REMOVED_PLACEMARKER_CLOSED,\n    ADDED_PLACEMARKER_OPEN,\n    ADDED_PLACEMARKER_CLOSED,\n    CHANGED_PLACEMARKER_OPEN,\n    CHANGED_PLACEMARKER_CLOSED,\n    CHANGED_INTO_PLACEMARKER_OPEN,\n    CHANGED_INTO_PLACEMARKER_CLOSED,\n)\n\n# Discord embed sidebar colors for different change types\nDISCORD_COLOR_UNCHANGED = 8421504   # Gray (#808080)\nDISCORD_COLOR_REMOVED = 16711680    # Red (#FF0000)\nDISCORD_COLOR_ADDED = 65280         # Green (#00FF00)\nDISCORD_COLOR_CHANGED = 16753920    # Orange (#FFA500)\nDISCORD_COLOR_CHANGED_INTO = 3447003  # Blue (#5865F2 - Discord blue)\nDISCORD_COLOR_WARNING = 16776960    # Yellow (#FFFF00)\n\n\nclass NotifyDiscordCustom(NotifyDiscord):\n    \"\"\"\n    Custom Discord notification handler that supports multiple colored embeds\n    for showing removed (red) and added (green) content separately.\n    \"\"\"\n\n    def send(self, body, title=\"\", notify_type=None, attach=None, **kwargs):\n        \"\"\"\n        Override send method to create custom embeds with red/green colors\n        for removed/added content when placeholders are present.\n        \"\"\"\n\n        # Check if body contains our diff placeholders\n        has_removed = REMOVED_PLACEMARKER_OPEN in body\n        has_added = ADDED_PLACEMARKER_OPEN in body\n        has_changed = CHANGED_PLACEMARKER_OPEN in body\n        has_changed_into = CHANGED_INTO_PLACEMARKER_OPEN in body\n\n        # If we have diff placeholders and we're in markdown/html format, create custom embeds\n        if (has_removed or has_added or has_changed or has_changed_into) and self.notify_format in (NotifyFormat.MARKDOWN, NotifyFormat.HTML):\n            return self._send_with_colored_embeds(body, title, notify_type, attach, **kwargs)\n\n        # Otherwise, use the parent class's default behavior\n        return super().send(body, title, notify_type, attach, **kwargs)\n\n    def _send_with_colored_embeds(self, body, title, notify_type, attach, **kwargs):\n        \"\"\"\n        Send Discord message with embeds in the original diff order.\n        Preserves the sequence: unchanged -> removed -> added -> unchanged, etc.\n        \"\"\"\n        from datetime import datetime, timezone\n\n        payload = {\n            \"tts\": self.tts,\n            \"wait\": self.tts is False,\n        }\n\n        if self.flags:\n            payload[\"flags\"] = self.flags\n\n        # Acquire image_url\n        image_url = self.image_url(notify_type)\n\n        if self.avatar and (image_url or self.avatar_url):\n            payload[\"avatar_url\"] = self.avatar_url if self.avatar_url else image_url\n\n        if self.user:\n            payload[\"username\"] = self.user\n\n        # Associate our thread_id with our message\n        params = {\"thread_id\": self.thread_id} if self.thread_id else None\n\n        # Build embeds array preserving order\n        embeds = []\n\n        # Add title as plain bold text in message content (not an embed)\n        if title:\n            payload[\"content\"] = f\"**{title}**\"\n\n        # Parse the body into ordered chunks\n        chunks = self._parse_body_into_chunks(body)\n\n        # Discord limits:\n        # - Max 10 embeds per message\n        # - Max 6000 characters total across all embeds\n        # - Max 4096 characters per embed description\n        max_embeds = 10\n        max_total_chars = 6000\n        max_embed_description = 4096\n\n        # All 10 embed slots are available for content\n        max_content_embeds = max_embeds\n\n        # Start character count\n        total_chars = 0\n\n        # Create embeds from chunks in order (no titles, just color coding)\n        for chunk_type, content in chunks:\n            if not content.strip():\n                continue\n\n            # Truncate individual embed description if needed\n            if len(content) > max_embed_description:\n                content = content[:max_embed_description - 3] + \"...\"\n\n            # Check if we're approaching the embed count limit\n            # We need room for the warning embed, so stop at max_content_embeds - 1\n            current_content_embeds = len(embeds)\n            if current_content_embeds >= max_content_embeds - 1:\n                # Add a truncation notice (this will be the 10th embed)\n                embeds.append({\n                    \"description\": \"⚠️ Content truncated (Discord 10 embed limit reached) - Tip: Select 'Plain Text' or 'HTML' format for longer diffs\",\n                    \"color\": DISCORD_COLOR_WARNING,\n                })\n                break\n\n            # Check if adding this embed would exceed total character limit\n            if total_chars + len(content) > max_total_chars:\n                # Add a truncation notice\n                remaining_chars = max_total_chars - total_chars\n                if remaining_chars > 100:\n                    # Add partial content if we have room\n                    truncated_content = content[:remaining_chars - 100] + \"...\"\n                    embeds.append({\n                        \"description\": truncated_content,\n                        \"color\": (DISCORD_COLOR_UNCHANGED if chunk_type == \"unchanged\"\n                                 else DISCORD_COLOR_REMOVED if chunk_type == \"removed\"\n                                 else DISCORD_COLOR_ADDED),\n                    })\n                embeds.append({\n                    \"description\": \"⚠️ Content truncated (Discord 6000 char limit reached)\\nTip: Select 'Plain Text' or 'HTML' format for longer diffs\",\n                    \"color\": DISCORD_COLOR_WARNING,\n                })\n                break\n\n            if chunk_type == \"unchanged\":\n                embeds.append({\n                    \"description\": content,\n                    \"color\": DISCORD_COLOR_UNCHANGED,\n                })\n            elif chunk_type == \"removed\":\n                embeds.append({\n                    \"description\": content,\n                    \"color\": DISCORD_COLOR_REMOVED,\n                })\n            elif chunk_type == \"added\":\n                embeds.append({\n                    \"description\": content,\n                    \"color\": DISCORD_COLOR_ADDED,\n                })\n            elif chunk_type == \"changed\":\n                # Changed (old value) - use orange to distinguish from pure removal\n                embeds.append({\n                    \"description\": content,\n                    \"color\": DISCORD_COLOR_CHANGED,\n                })\n            elif chunk_type == \"changed_into\":\n                # Changed into (new value) - use blue to distinguish from pure addition\n                embeds.append({\n                    \"description\": content,\n                    \"color\": DISCORD_COLOR_CHANGED_INTO,\n                })\n\n            total_chars += len(content)\n\n        if embeds:\n            payload[\"embeds\"] = embeds\n\n        # Send the payload using parent's _send method\n        if not self._send(payload, params=params):\n            return False\n\n        # Handle attachments if present\n        if attach and self.attachment_support:\n            payload.update({\n                \"tts\": False,\n                \"wait\": True,\n            })\n            payload.pop(\"embeds\", None)\n            payload.pop(\"content\", None)\n            payload.pop(\"allow_mentions\", None)\n\n            for attachment in attach:\n                self.logger.info(f\"Posting Discord Attachment {attachment.name}\")\n                if not self._send(payload, params=params, attach=attachment):\n                    return False\n\n        return True\n\n    def _parse_body_into_chunks(self, body):\n        \"\"\"\n        Parse the body into ordered chunks of (type, content) tuples.\n        Types: \"unchanged\", \"removed\", \"added\", \"changed\", \"changed_into\"\n        Preserves the original order of the diff.\n        \"\"\"\n        chunks = []\n        position = 0\n\n        while position < len(body):\n            # Find the next marker\n            next_removed = body.find(REMOVED_PLACEMARKER_OPEN, position)\n            next_added = body.find(ADDED_PLACEMARKER_OPEN, position)\n            next_changed = body.find(CHANGED_PLACEMARKER_OPEN, position)\n            next_changed_into = body.find(CHANGED_INTO_PLACEMARKER_OPEN, position)\n\n            # Determine which marker comes first\n            if next_removed == -1 and next_added == -1 and next_changed == -1 and next_changed_into == -1:\n                # No more markers, rest is unchanged\n                if position < len(body):\n                    chunks.append((\"unchanged\", body[position:]))\n                break\n\n            # Find the earliest marker\n            next_marker_pos = None\n            next_marker_type = None\n\n            # Compare all marker positions to find the earliest\n            markers = []\n            if next_removed != -1:\n                markers.append((next_removed, \"removed\"))\n            if next_added != -1:\n                markers.append((next_added, \"added\"))\n            if next_changed != -1:\n                markers.append((next_changed, \"changed\"))\n            if next_changed_into != -1:\n                markers.append((next_changed_into, \"changed_into\"))\n\n            if markers:\n                next_marker_pos, next_marker_type = min(markers, key=lambda x: x[0])\n\n            # Add unchanged content before the marker\n            if next_marker_pos > position:\n                chunks.append((\"unchanged\", body[position:next_marker_pos]))\n\n            # Find the closing marker\n            if next_marker_type == \"removed\":\n                open_marker = REMOVED_PLACEMARKER_OPEN\n                close_marker = REMOVED_PLACEMARKER_CLOSED\n            elif next_marker_type == \"added\":\n                open_marker = ADDED_PLACEMARKER_OPEN\n                close_marker = ADDED_PLACEMARKER_CLOSED\n            elif next_marker_type == \"changed\":\n                open_marker = CHANGED_PLACEMARKER_OPEN\n                close_marker = CHANGED_PLACEMARKER_CLOSED\n            else:  # changed_into\n                open_marker = CHANGED_INTO_PLACEMARKER_OPEN\n                close_marker = CHANGED_INTO_PLACEMARKER_CLOSED\n\n            close_pos = body.find(close_marker, next_marker_pos)\n\n            if close_pos == -1:\n                # No closing marker, take rest as this type\n                content = body[next_marker_pos + len(open_marker):]\n                chunks.append((next_marker_type, content))\n                break\n            else:\n                # Extract content between markers\n                content = body[next_marker_pos + len(open_marker):close_pos]\n                chunks.append((next_marker_type, content))\n                position = close_pos + len(close_marker)\n\n        return chunks\n\n\n# Register the custom Discord handler with Apprise\n# This will override the built-in discord:// handler\n@notify(on=\"discord\")\ndef discord_custom_wrapper(body, title, notify_type, meta, body_format=None, *args, **kwargs):\n    \"\"\"\n    Wrapper function to make the custom Discord handler work with Apprise's decorator system.\n    Note: This decorator approach may not work for overriding built-in plugins.\n    The class-based approach above is the proper way to extend NotifyDiscord.\n    \"\"\"\n    logger.info(\"Custom Discord handler called\")\n    # This is here for potential future use with decorator-based registration\n    return True\n"
  },
  {
    "path": "changedetectionio/notification/email_helpers.py",
    "content": "def as_monospaced_html_email(content: str, title: str) -> str:\n    \"\"\"\n    Wraps `content` in a minimal, email-safe HTML template\n    that forces monospace rendering across Gmail, Hotmail, Apple Mail, etc.\n\n    Args:\n        content: The body text (plain text or HTML-like).\n        title: The title plaintext\n    Returns:\n        A complete HTML document string suitable for sending as an email body.\n    \"\"\"\n\n    # All line feed types should be removed and then this function should only be fed <br>'s\n    # Then it works with our <pre> styling without double linefeeds\n    content = content.translate(str.maketrans('', '', '\\r\\n'))\n\n    if title:\n        import html\n        title = html.escape(title)\n    else:\n        title = ''\n    # 2. Full email-safe HTML\n    html_email = f\"\"\"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <!--[if mso]>\n    <style>\n      body, div, pre, td {{ font-family: \"Courier New\", Courier, monospace !important; }}\n    </style>\n  <![endif]-->\n  <title>{title}</title>\n</head>\n<body style=\"-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;\">\n  <pre role=\"article\" aria-roledescription=\"email\" lang=\"en\"\n       style=\"font-family: monospace, 'Courier New', Courier; font-size: 0.9rem;\n              white-space: pre-wrap; word-break: break-word;\">{content}</pre>\n</body>\n</html>\"\"\"\n    return html_email"
  },
  {
    "path": "changedetectionio/notification/handler.py",
    "content": "\nimport time\nimport re\nimport apprise\nfrom apprise import NotifyFormat\nfrom loguru import logger\nfrom urllib.parse import urlparse\nfrom .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL\nfrom .email_helpers import as_monospaced_html_email\nfrom ..diff import HTML_REMOVED_STYLE, REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED, ADDED_PLACEMARKER_OPEN, HTML_ADDED_STYLE, \\\n    ADDED_PLACEMARKER_CLOSED, CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED, CHANGED_PLACEMARKER_OPEN, \\\n    CHANGED_PLACEMARKER_CLOSED, HTML_CHANGED_STYLE, HTML_CHANGED_INTO_STYLE\nimport re\n\nfrom ..notification_service import NotificationContextData, add_rendered_diff_to_notification_vars\n\nnewline_re = re.compile(r'\\r\\n|\\r|\\n')\n\ndef markup_text_links_to_html(body):\n    \"\"\"\n    Convert plaintext to HTML with clickable links.\n    Uses Jinja2's escape and Markup for XSS safety.\n    \"\"\"\n    from linkify_it import LinkifyIt\n    from markupsafe import Markup, escape\n\n    linkify = LinkifyIt()\n\n    # Match URLs in the ORIGINAL text (before escaping)\n    matches = linkify.match(body)\n\n    if not matches:\n        # No URLs, just escape everything\n        return Markup(escape(body))\n\n    result = []\n    last_index = 0\n\n    # Process each URL match\n    for match in matches:\n        # Add escaped text before the URL\n        if match.index > last_index:\n            text_part = body[last_index:match.index]\n            result.append(escape(text_part))\n\n        # Add the link with escaped URL (both in href and display)\n        url = match.url\n        result.append(Markup(f'<a href=\"{escape(url)}\">{escape(url)}</a>'))\n\n        last_index = match.last_index\n\n    # Add remaining escaped text\n    if last_index < len(body):\n        result.append(escape(body[last_index:]))\n\n    # Join all parts\n    return str(Markup(''.join(str(part) for part in result)))\n\ndef notification_format_align_with_apprise(n_format : str):\n    \"\"\"\n    Correctly align changedetection's formats with apprise's formats\n    Probably these are the same - but good to be sure.\n    These set the expected OUTPUT format type\n    :param n_format:\n    :return:\n    \"\"\"\n\n    if n_format.startswith('html'):\n        # Apprise only knows 'html' not 'htmlcolor' etc, which shouldnt matter here\n        n_format = NotifyFormat.HTML.value\n    elif n_format.startswith('markdown'):\n        # probably the same but just to be safe\n        n_format = NotifyFormat.MARKDOWN.value\n    elif n_format.startswith('text'):\n        # probably the same but just to be safe\n        n_format = NotifyFormat.TEXT.value\n    else:\n        n_format = NotifyFormat.TEXT.value\n\n    return n_format\n\n\ndef apply_html_color_to_body(n_body: str):\n    # https://github.com/dgtlmoon/changedetection.io/issues/821#issuecomment-1241837050\n    n_body = n_body.replace(REMOVED_PLACEMARKER_OPEN,\n                            f'<span style=\"{HTML_REMOVED_STYLE}\" role=\"deletion\" aria-label=\"Removed text\" title=\"Removed text\">')\n    n_body = n_body.replace(REMOVED_PLACEMARKER_CLOSED, f'</span>')\n    n_body = n_body.replace(ADDED_PLACEMARKER_OPEN,\n                            f'<span style=\"{HTML_ADDED_STYLE}\" role=\"insertion\" aria-label=\"Added text\" title=\"Added text\">')\n    n_body = n_body.replace(ADDED_PLACEMARKER_CLOSED, f'</span>')\n    # Handle changed/replaced lines (old → new)\n    n_body = n_body.replace(CHANGED_PLACEMARKER_OPEN,\n                            f'<span style=\"{HTML_CHANGED_STYLE}\" role=\"note\" aria-label=\"Changed text\" title=\"Changed text\">')\n    n_body = n_body.replace(CHANGED_PLACEMARKER_CLOSED, f'</span>')\n    n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_OPEN,\n                            f'<span style=\"{HTML_CHANGED_INTO_STYLE}\" role=\"note\" aria-label=\"Changed into\" title=\"Changed into\">')\n    n_body = n_body.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'</span>')\n    return n_body\n\ndef apply_discord_markdown_to_body(n_body):\n    \"\"\"\n    Discord does not support <del> but it supports non-standard ~~strikethrough~~\n    :param n_body:\n    :return:\n    \"\"\"\n    import re\n    # Define the mapping between your placeholders and markdown markers\n    replacements = [\n        (REMOVED_PLACEMARKER_OPEN, '~~', REMOVED_PLACEMARKER_CLOSED, '~~'),\n        (ADDED_PLACEMARKER_OPEN, '**', ADDED_PLACEMARKER_CLOSED, '**'),\n        (CHANGED_PLACEMARKER_OPEN, '~~', CHANGED_PLACEMARKER_CLOSED, '~~'),\n        (CHANGED_INTO_PLACEMARKER_OPEN, '**', CHANGED_INTO_PLACEMARKER_CLOSED, '**'),\n    ]\n    # So that the markdown gets added without any whitespace following it which would break it\n    for open_tag, open_md, close_tag, close_md in replacements:\n        # Regex: match opening tag, optional whitespace, capture the content, optional whitespace, then closing tag\n        pattern = re.compile(\n            re.escape(open_tag) + r'(\\s*)(.*?)?(\\s*)' + re.escape(close_tag),\n            flags=re.DOTALL\n        )\n        n_body = pattern.sub(lambda m: f\"{m.group(1)}{open_md}{m.group(2)}{close_md}{m.group(3)}\", n_body)\n    return n_body\n\ndef apply_standard_markdown_to_body(n_body):\n    \"\"\"\n    Apprise does not support ~~strikethrough~~ but it will convert <del> to HTML strikethrough.\n    :param n_body:\n    :return:\n    \"\"\"\n    import re\n    # Define the mapping between your placeholders and markdown markers\n    replacements = [\n        (REMOVED_PLACEMARKER_OPEN, '<del>', REMOVED_PLACEMARKER_CLOSED, '</del>'),\n        (ADDED_PLACEMARKER_OPEN, '**', ADDED_PLACEMARKER_CLOSED, '**'),\n        (CHANGED_PLACEMARKER_OPEN, '<del>', CHANGED_PLACEMARKER_CLOSED, '</del>'),\n        (CHANGED_INTO_PLACEMARKER_OPEN, '**', CHANGED_INTO_PLACEMARKER_CLOSED, '**'),\n    ]\n\n    # So that the markdown gets added without any whitespace following it which would break it\n    for open_tag, open_md, close_tag, close_md in replacements:\n        # Regex: match opening tag, optional whitespace, capture the content, optional whitespace, then closing tag\n        pattern = re.compile(\n            re.escape(open_tag) + r'(\\s*)(.*?)?(\\s*)' + re.escape(close_tag),\n            flags=re.DOTALL\n        )\n        n_body = pattern.sub(lambda m: f\"{m.group(1)}{open_md}{m.group(2)}{close_md}{m.group(3)}\", n_body)\n    return n_body\n\n\ndef replace_placemarkers_in_text(text, url, requested_output_format):\n    \"\"\"\n    Replace diff placemarkers in text based on the URL service type and requested output format.\n    Used for both notification title and body to ensure consistent placeholder replacement.\n\n    :param text: The text to process\n    :param url: The notification URL (to detect service type)\n    :param requested_output_format: The output format (html, htmlcolor, markdown, text, etc.)\n    :return: Processed text with placemarkers replaced\n    \"\"\"\n    if not text:\n        return text\n\n    if url.startswith('tgram://'):\n        # Telegram only supports a limited subset of HTML\n        # Use strikethrough for removed content, bold for added content\n        text = text.replace(REMOVED_PLACEMARKER_OPEN, '<s>')\n        text = text.replace(REMOVED_PLACEMARKER_CLOSED, '</s>')\n        text = text.replace(ADDED_PLACEMARKER_OPEN, '<b>')\n        text = text.replace(ADDED_PLACEMARKER_CLOSED, '</b>')\n        # Handle changed/replaced lines (old → new)\n        text = text.replace(CHANGED_PLACEMARKER_OPEN, '<s>')\n        text = text.replace(CHANGED_PLACEMARKER_CLOSED, '</s>')\n        text = text.replace(CHANGED_INTO_PLACEMARKER_OPEN, '<b>')\n        text = text.replace(CHANGED_INTO_PLACEMARKER_CLOSED, '</b>')\n    elif (url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks')\n          or url.startswith('https://discord.com/api')) and requested_output_format == 'html':\n        # Discord doesn't support HTML, use Discord markdown\n        text = apply_discord_markdown_to_body(n_body=text)\n    elif requested_output_format == 'htmlcolor':\n        # https://github.com/dgtlmoon/changedetection.io/issues/821#issuecomment-1241837050\n        text = text.replace(REMOVED_PLACEMARKER_OPEN, f'<span style=\"{HTML_REMOVED_STYLE}\" role=\"deletion\" aria-label=\"Removed text\" title=\"Removed text\">')\n        text = text.replace(REMOVED_PLACEMARKER_CLOSED, f'</span>')\n        text = text.replace(ADDED_PLACEMARKER_OPEN, f'<span style=\"{HTML_ADDED_STYLE}\" role=\"insertion\" aria-label=\"Added text\" title=\"Added text\">')\n        text = text.replace(ADDED_PLACEMARKER_CLOSED, f'</span>')\n        # Handle changed/replaced lines (old → new)\n        text = text.replace(CHANGED_PLACEMARKER_OPEN, f'<span style=\"{HTML_CHANGED_STYLE}\" role=\"note\" aria-label=\"Changed text\" title=\"Changed text\">')\n        text = text.replace(CHANGED_PLACEMARKER_CLOSED, f'</span>')\n        text = text.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'<span style=\"{HTML_CHANGED_INTO_STYLE}\" role=\"note\" aria-label=\"Changed into\" title=\"Changed into\">')\n        text = text.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'</span>')\n    elif requested_output_format == 'markdown':\n        # Markdown to HTML - Apprise will convert this to HTML\n        text = apply_standard_markdown_to_body(n_body=text)\n    else:\n        # plaintext, html, and default - use simple text markers\n        text = text.replace(REMOVED_PLACEMARKER_OPEN, '(removed) ')\n        text = text.replace(REMOVED_PLACEMARKER_CLOSED, '')\n        text = text.replace(ADDED_PLACEMARKER_OPEN, '(added) ')\n        text = text.replace(ADDED_PLACEMARKER_CLOSED, '')\n        text = text.replace(CHANGED_PLACEMARKER_OPEN, f'(changed) ')\n        text = text.replace(CHANGED_PLACEMARKER_CLOSED, f'')\n        text = text.replace(CHANGED_INTO_PLACEMARKER_OPEN, f'(into) ')\n        text = text.replace(CHANGED_INTO_PLACEMARKER_CLOSED, f'')\n\n    return text\n\ndef apply_service_tweaks(url, n_body, n_title, requested_output_format):\n\n    logger.debug(f\"Applying markup in '{requested_output_format}' mode\")\n\n    # Re 323 - Limit discord length to their 2000 char limit total or it wont send.\n    # Because different notifications may require different pre-processing, run each sequentially :(\n    # 2000 bytes minus -\n    #     200 bytes for the overhead of the _entire_ json payload, 200 bytes for {tts, wait, content} etc headers\n    #     Length of URL - Incase they specify a longer custom avatar_url\n\n    if not n_body or not n_body.strip():\n        return url, n_body, n_title\n\n    # Normalize URL scheme to lowercase to prevent case-sensitivity issues\n    # e.g., \"Discord://webhook\" -> \"discord://webhook\", \"TGRAM://bot123\" -> \"tgram://bot123\"\n    scheme_separator_pos = url.find('://')\n    if scheme_separator_pos > 0:\n        url = url[:scheme_separator_pos].lower() + url[scheme_separator_pos:]\n\n    # So if no avatar_url is specified, add one so it can be correctly calculated into the total payload\n    parsed = urlparse(url)\n    k = '?' if not parsed.query else '&'\n    if url and not 'avatar_url' in url \\\n            and not url.startswith('mail') \\\n            and not url.startswith('post') \\\n            and not url.startswith('get') \\\n            and not url.startswith('delete') \\\n            and not url.startswith('put'):\n        url += k + f\"avatar_url={APPRISE_AVATAR_URL}\"\n\n    # Replace placemarkers in title first (this was the missing piece causing the bug)\n    # Titles are ALWAYS plain text across all notification services (Discord embeds, Slack attachments,\n    # email Subject headers, etc.), so we always use 'text' format for title placemarker replacement\n    # Looking over apprise library it seems that all plugins only expect plain-text.\n    n_title = replace_placemarkers_in_text(n_title, url, 'text')\n\n    if url.startswith('tgram://'):\n        # Telegram only supports a limit subset of HTML, remove the '<br>' we place in.\n        # re https://github.com/dgtlmoon/changedetection.io/issues/555\n        # @todo re-use an existing library we have already imported to strip all non-allowed tags\n        n_body = n_body.replace('<br>', '\\n')\n        n_body = n_body.replace('</br>', '\\n')\n        n_body = newline_re.sub('\\n', n_body)\n\n        # Replace placemarkers for body\n        n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)\n\n        # real limit is 4096, but minus some for extra metadata\n        payload_max_size = 3600\n        body_limit = max(0, payload_max_size - len(n_title))\n        n_title = n_title[0:payload_max_size]\n        n_body = n_body[0:body_limit]\n\n    elif (url.startswith('discord://') or url.startswith('https://discordapp.com/api/webhooks')\n          or url.startswith('https://discord.com/api'))\\\n            and 'html' in requested_output_format:\n        # Discord doesn't support HTML, replace <br> with newlines\n        n_body = n_body.strip().replace('<br>', '\\n')\n        n_body = n_body.replace('</br>', '\\n')\n        n_body = newline_re.sub('\\n', n_body)\n\n        # Don't replace placeholders or truncate here - let the custom Discord plugin handle it\n        # The plugin will use embeds (6000 char limit across all embeds) if placeholders are present,\n        # or plain content (2000 char limit) otherwise\n\n        # Only do placeholder replacement if NOT using htmlcolor (which triggers embeds in custom plugin)\n        if requested_output_format == 'html':\n            # No diff placeholders, use Discord markdown for any other formatting\n            # Use Discord markdown: strikethrough for removed, bold for added\n            n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)\n\n            # Apply 2000 char limit for plain content\n            payload_max_size = 1700\n            body_limit = max(0, payload_max_size - len(n_title))\n            n_title = n_title[0:payload_max_size]\n            n_body = n_body[0:body_limit]\n        # else: our custom Discord plugin will convert any placeholders left over into embeds with color bars\n\n    # Is not discord/tgram and they want htmlcolor\n    elif requested_output_format == 'htmlcolor':\n        n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)\n        n_body = newline_re.sub('<br>\\n', n_body)\n    elif requested_output_format == 'html':\n        n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)\n        n_body = newline_re.sub('<br>\\n', n_body)\n    elif requested_output_format == 'markdown':\n        # Markdown to HTML - Apprise will convert this to HTML\n        n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)\n\n    else: #plaintext etc default\n        n_body = replace_placemarkers_in_text(n_body, url, requested_output_format)\n\n    return url, n_body, n_title\n\n\ndef process_notification(n_object: NotificationContextData, datastore):\n    from changedetectionio.jinja2_custom import render as jinja_render\n    from . import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH, default_notification_format, valid_notification_formats\n    # be sure its registered\n    from .apprise_plugin.custom_handlers import apprise_http_custom_handler\n    # Register custom Discord plugin\n    from .apprise_plugin.discord import NotifyDiscordCustom\n\n    if not isinstance(n_object, NotificationContextData):\n        raise TypeError(f\"Expected NotificationContextData, got {type(n_object)}\")\n\n    now = time.time()\n    if n_object.get('notification_timestamp'):\n        logger.trace(f\"Time since queued {now-n_object['notification_timestamp']:.3f}s\")\n\n    # Insert variables into the notification content\n    notification_parameters = create_notification_parameters(n_object, datastore)\n\n    requested_output_format = n_object.get('notification_format', default_notification_format)\n    logger.debug(f\"Requested notification output format: '{requested_output_format}'\")\n\n    # If we arrived with 'System default' then look it up\n    if requested_output_format == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:\n        # Initially text or whatever\n        requested_output_format = datastore.data['settings']['application'].get('notification_format', default_notification_format)\n\n    requested_output_format_original = requested_output_format\n\n    # Now clean it up so it fits perfectly with apprise\n    requested_output_format = notification_format_align_with_apprise(n_format=requested_output_format)\n\n    logger.trace(f\"Complete notification body including Jinja and placeholders calculated in  {time.time() - now:.2f}s\")\n\n    # https://github.com/caronc/apprise/wiki/Development_LogCapture\n    # Anything higher than or equal to WARNING (which covers things like Connection errors)\n    # raise it as an exception\n\n    sent_objs = []\n\n    if 'as_async' in n_object:\n        apprise_asset.async_mode = n_object.get('as_async')\n\n    apobj = apprise.Apprise(debug=True, asset=apprise_asset)\n\n    # Override Apprise's built-in Discord plugin with our custom one\n    # This allows us to use colored embeds for diff content\n    # First remove the built-in discord plugin, then add our custom one\n    apprise.plugins.N_MGR.remove('discord')\n    apprise.plugins.N_MGR.add(NotifyDiscordCustom, schemas='discord')\n\n    if not n_object.get('notification_urls'):\n        return None\n\n    n_object.update(add_rendered_diff_to_notification_vars(\n        notification_scan_text=n_object.get('notification_body', '')+n_object.get('notification_title', ''),\n        current_snapshot=n_object.get('current_snapshot'),\n        prev_snapshot=n_object.get('prev_snapshot'),\n        # Should always be false for 'text' mode or its too hard to read\n        # But otherwise, this could be some setting\n        word_diff=False if requested_output_format_original == 'text' else True,\n        )\n    )\n\n    with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):\n        for url in n_object['notification_urls']:\n\n            n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)\n            n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)\n\n            if n_object.get('markup_text_links_to_html_links'):\n                n_body = markup_text_links_to_html(body=n_body)\n\n            url = url.strip()\n            if not url or url.startswith('#'):\n                logger.debug(f\"Skipping commented out or empty notification URL - '{url}'\")\n                continue\n\n            logger.info(f\">> Process Notification: AppRise start notifying '{url}'\")\n            url = jinja_render(template_str=url, **notification_parameters)\n\n            # If it's a plaintext document, and they want HTML type email/alerts, so it needs to be escaped\n            watch_mime_type = n_object.get('watch_mime_type')\n            if watch_mime_type and 'text/' in watch_mime_type.lower() and not 'html' in watch_mime_type.lower():\n                if 'html' in requested_output_format:\n                    from markupsafe import escape\n                    n_body = str(escape(n_body))\n\n            if 'html' in requested_output_format:\n                # Since the n_body is always some kind of text from the 'diff' engine, attempt to preserve whitespaces that get sent to the HTML output\n                # But only where its more than 1 consecutive whitespace, otherwise \"and this\" becomes \"and&nbsp;this\" etc which is too much.\n                n_body = n_body.replace('  ', '&nbsp;&nbsp;')\n\n            (url, n_body, n_title) = apply_service_tweaks(url=url, n_body=n_body, n_title=n_title, requested_output_format=requested_output_format_original)\n\n            apprise_input_format = \"NO-THANKS-WE-WILL-MANAGE-ALL-OF-THIS\"\n\n            if not 'format=' in url:\n                parsed_url = urlparse(url)\n                prefix_add_to_url = '?' if not parsed_url.query else '&'\n\n                # THIS IS THE TRICK HOW TO DISABLE APPRISE DOING WEIRD AUTO-CONVERSION WITH BREAKING BR TAGS ETC\n                if 'html' in requested_output_format:\n                    url = f\"{url}{prefix_add_to_url}format={NotifyFormat.HTML.value}\"\n                    apprise_input_format = NotifyFormat.HTML.value\n                elif 'text' in requested_output_format:\n                    url = f\"{url}{prefix_add_to_url}format={NotifyFormat.TEXT.value}\"\n                    apprise_input_format = NotifyFormat.TEXT.value\n\n                elif requested_output_format == NotifyFormat.MARKDOWN.value:\n                    # Convert markdown to HTML ourselves since not all plugins do this\n                    from apprise.conversion import markdown_to_html\n                    # Make sure there are paragraph breaks around horizontal rules\n                    n_body = n_body.replace('---', '\\n\\n---\\n\\n')\n                    n_body = markdown_to_html(n_body)\n                    url = f\"{url}{prefix_add_to_url}format={NotifyFormat.HTML.value}\"\n                    requested_output_format = NotifyFormat.HTML.value\n                    apprise_input_format = NotifyFormat.HTML.value  # Changed from MARKDOWN to HTML\n\n            else:\n                # ?format was IN the apprise URL, they are kind of on their own here, we will try our best\n                if 'format=html' in url:\n                    n_body = newline_re.sub('<br>\\r\\n', n_body)\n                    # This will also prevent apprise from doing conversion\n                    apprise_input_format = NotifyFormat.HTML.value\n                    requested_output_format = NotifyFormat.HTML.value\n                elif 'format=text' in url:\n                    apprise_input_format = NotifyFormat.TEXT.value\n                    requested_output_format = NotifyFormat.TEXT.value\n\n#@todo on null:// (only if its a 1 url with null) probably doesnt need to actually .add/setup/etc\n            sent_objs.append({'title': n_title,\n                              'body': n_body,\n                              'url': url,\n                              # So that we can do a null:// call and get back exactly what would have been sent\n                              'original_context': n_object })\n\n            if not url.startswith('null://'):\n                apobj.add(url)\n\n            # Since the output is always based on the plaintext of the 'diff' engine, wrap it nicely.\n            # It should always be similar to the 'history' part of the UI.\n            if url.startswith('mail') and 'html' in requested_output_format:\n                if not '<pre' in n_body and not '<body' in n_body: # No custom HTML-ish body was setup already\n                    n_body = as_monospaced_html_email(content=n_body, title=n_title)\n\n        if not url.startswith('null://'):\n            apobj.notify(\n                title=n_title,\n                body=n_body,\n                # `body_format` Tell apprise what format the INPUT is in, specify a wrong/bad type and it will force skip conversion in apprise\n                # &format= in URL Tell apprise what format the OUTPUT should be in (it can convert between)\n                body_format=apprise_input_format,\n                # False is not an option for AppRise, must be type None\n                attach=n_object.get('screenshot', None)\n            )\n\n        # Returns empty string if nothing found, multi-line string otherwise\n        log_value = logs.getvalue()\n\n        if log_value and ('WARNING' in log_value or 'ERROR' in log_value):\n            logger.critical(log_value)\n            raise Exception(log_value)\n\n    # Return what was sent for better logging - after the for loop\n    return sent_objs\n\n\n# Notification title + body content parameters get created here.\n# ( Where we prepare the tokens in the notification to be replaced with actual values )\ndef create_notification_parameters(n_object: NotificationContextData, datastore):\n    if not isinstance(n_object, NotificationContextData):\n        raise TypeError(f\"Expected NotificationContextData, got {type(n_object)}\")\n\n    ext_base_url = datastore.data['settings']['application'].get('active_base_url').strip('/')+'/'\n\n    watch = datastore.data['watching'].get(n_object['uuid'])\n    if watch:\n        watch_title = datastore.data['watching'][n_object['uuid']].label\n        tag_list = []\n        tags = datastore.get_all_tags_for_watch(n_object['uuid'])\n        if tags:\n            for tag_uuid, tag in tags.items():\n                tag_list.append(tag.get('title'))\n        watch_tag = ', '.join(tag_list)\n    else:\n        watch_title = 'Change Detection'\n        watch_tag = ''\n\n    watch_url = n_object['watch_url']\n\n    # Build URLs manually instead of using url_for() to avoid requiring a request context\n    # This allows notifications to be processed in background threads\n    uuid = n_object['uuid']\n\n    if n_object.get('timestamp_from') and n_object.get('timestamp_to'):\n        # Include a link to the diff page with specific versions\n        diff_url = f\"{ext_base_url}diff/{uuid}?from_version={n_object['timestamp_from']}&to_version={n_object['timestamp_to']}\"\n    else:\n        diff_url = f\"{ext_base_url}diff/{uuid}\"\n\n    preview_url = f\"{ext_base_url}preview/{uuid}\"\n    edit_url = f\"{ext_base_url}edit/{uuid}\"\n\n    # @todo test that preview_url is correct when running in not-null mode?\n    # if not, first time app loads i think it can set a flask context\n    n_object.update(\n        {\n            'base_url': ext_base_url,\n            'diff_url': diff_url,\n            'preview_url': preview_url, #@todo include 'version='\n            'edit_url': edit_url, #@todo also pause, also mute link\n            'watch_tag': watch_tag if watch_tag is not None else '',\n            'watch_title': watch_title if watch_title is not None else '',\n            'watch_url': watch_url,\n            'watch_uuid': n_object['uuid'],\n        })\n\n    if watch:\n        n_object.update(datastore.data['watching'].get(n_object['uuid']).extra_notification_token_values())\n\n    return n_object\n"
  },
  {
    "path": "changedetectionio/notification_service.py",
    "content": "#!/usr/bin/env python3\n\n\"\"\"\nNotification Service Module\nExtracted from update_worker.py to provide standalone notification functionality\nfor both sync and async workers\n\"\"\"\nimport datetime\n\nimport pytz\nfrom loguru import logger\nimport time\n\nfrom changedetectionio.notification import default_notification_format, valid_notification_formats\n\n\ndef _check_cascading_vars(datastore, var_name, watch):\n    \"\"\"\n    Check notification variables in cascading priority:\n    Individual watch settings > Tag settings > Global settings\n    \"\"\"\n    from changedetectionio.notification import (\n        USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH,\n        default_notification_body,\n        default_notification_title\n    )\n\n    # Would be better if this was some kind of Object where Watch can reference the parent datastore etc\n    v = watch.get(var_name)\n    if v and not watch.get('notification_muted'):\n        if var_name == 'notification_format' and v == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:\n            return datastore.data['settings']['application'].get('notification_format')\n\n        return v\n\n    tags = datastore.get_all_tags_for_watch(uuid=watch.get('uuid'))\n    if tags:\n        for tag_uuid, tag in tags.items():\n            v = tag.get(var_name)\n            if v and not tag.get('notification_muted'):\n                return v\n\n    if datastore.data['settings']['application'].get(var_name):\n        return datastore.data['settings']['application'].get(var_name)\n\n    # Otherwise could be defaults\n    if var_name == 'notification_format':\n        return USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n    if var_name == 'notification_body':\n        return default_notification_body\n    if var_name == 'notification_title':\n        return default_notification_title\n\n    return None\n\n\nclass FormattableTimestamp(str):\n    \"\"\"\n    A str subclass representing a formatted datetime. As a plain string it renders\n    with the default format, but can also be called with a custom format argument\n    in Jinja2 templates:\n\n        {{ change_datetime }}                        → '2024-01-15 10:30:00 UTC'\n        {{ change_datetime(format='%Y') }}           → '2024'\n        {{ change_datetime(format='%A') }}           → 'Monday'\n        {{ change_datetime(format='%Y-%m-%d') }}     → '2024-01-15'\n\n    Being a str subclass means it is natively JSON serializable.\n    \"\"\"\n    _DEFAULT_FORMAT = '%Y-%m-%d %H:%M:%S %Z'\n\n    def __new__(cls, timestamp):\n        dt = datetime.datetime.fromtimestamp(int(timestamp), tz=pytz.UTC)\n        local_tz = datetime.datetime.now().astimezone().tzinfo\n        dt_local = dt.astimezone(local_tz)\n        try:\n            formatted = dt_local.strftime(cls._DEFAULT_FORMAT)\n        except Exception:\n            formatted = dt_local.isoformat()\n        instance = super().__new__(cls, formatted)\n        instance._dt = dt_local\n        return instance\n\n    def __call__(self, format=_DEFAULT_FORMAT):\n        try:\n            return self._dt.strftime(format)\n        except Exception:\n            return self._dt.isoformat()\n\n\nclass FormattableDiff(str):\n    \"\"\"\n    A str subclass representing a rendered diff. As a plain string it renders\n    with the default options for that variant, but can be called with custom\n    arguments in Jinja2 templates:\n\n        {{ diff }}                                    → default diff output\n        {{ diff(lines=5) }}                           → truncate to 5 lines\n        {{ diff(added_only=true) }}                   → only show added lines\n        {{ diff(removed_only=true) }}                 → only show removed lines\n        {{ diff(context=3) }}                         → 3 lines of context around changes\n        {{ diff(word_diff=false) }}                   → line-level diff instead of word-level\n        {{ diff(lines=10, added_only=true) }}         → combine args\n        {{ diff_added(lines=5) }}                     → works on any diff_* variant too\n\n    Being a str subclass means it is natively JSON serializable.\n    \"\"\"\n    def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):\n        if prev_snapshot or current_snapshot:\n            from changedetectionio import diff as diff_module\n            rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs)\n        else:\n            rendered = ''\n        instance = super().__new__(cls, rendered)\n        instance._prev = prev_snapshot\n        instance._current = current_snapshot\n        instance._base_kwargs = base_kwargs\n        return instance\n\n    def __call__(self, lines=None, added_only=False, removed_only=False, context=0,\n                 word_diff=None, case_insensitive=False, ignore_junk=False):\n        from changedetectionio import diff as diff_module\n        kwargs = dict(self._base_kwargs)\n\n        if added_only:\n            kwargs['include_removed'] = False\n        if removed_only:\n            kwargs['include_added'] = False\n        if context:\n            kwargs['context_lines'] = int(context)\n        if word_diff is not None:\n            kwargs['word_diff'] = bool(word_diff)\n        if case_insensitive:\n            kwargs['case_insensitive'] = True\n        if ignore_junk:\n            kwargs['ignore_junk'] = True\n\n        result = diff_module.render_diff(self._prev or '', self._current or '', **kwargs)\n\n        if lines is not None:\n            result = '\\n'.join(result.splitlines()[:int(lines)])\n\n        return result\n\n\n\n# What is passed around as notification context, also used as the complete list of valid {{ tokens }}\nclass NotificationContextData(dict):\n    def __init__(self, initial_data=None, **kwargs):\n        # ValidateJinja2Template() validates against the keynames of this dict to check for valid tokens in the body (user submission)\n        super().__init__({\n            'base_url': None,\n            'change_datetime': FormattableTimestamp(time.time()),\n            'current_snapshot': None,\n            'diff': FormattableDiff('', ''),\n            'diff_clean': FormattableDiff('', '', include_change_type_prefix=False),\n            'diff_added': FormattableDiff('', '', include_removed=False),\n            'diff_added_clean': FormattableDiff('', '', include_removed=False, include_change_type_prefix=False),\n            'diff_full': FormattableDiff('', '', include_equal=True),\n            'diff_full_clean': FormattableDiff('', '', include_equal=True, include_change_type_prefix=False),\n            'diff_patch': FormattableDiff('', '', patch_format=True),\n            'diff_removed': FormattableDiff('', '', include_added=False),\n            'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),\n            'diff_url': None,\n            'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen\n            'notification_timestamp': time.time(),\n            'prev_snapshot': None,\n            'preview_url': None,\n            'screenshot': None,\n            'timestamp_from': None,\n            'timestamp_to': None,\n            'triggered_text': None,\n            'uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX',  # Converted to 'watch_uuid' in create_notification_parameters\n            'watch_mime_type': None,\n            'watch_tag': None,\n            'watch_title': None,\n            'watch_url': 'https://WATCH-PLACE-HOLDER/',\n            'watch_uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX',  # Converted to 'watch_uuid' in create_notification_parameters\n        })\n\n        # Apply any initial data passed in\n        self.update({'watch_uuid': self.get('uuid')})\n        if initial_data:\n            self.update(initial_data)\n\n        # Apply any keyword arguments\n        if kwargs:\n            self.update(kwargs)\n\n        n_format = self.get('notification_format')\n        if n_format and not valid_notification_formats.get(n_format):\n            raise ValueError(f'Invalid notification format: \"{n_format}\"')\n\n    def set_random_for_validation(self):\n        import random, string\n        \"\"\"Randomly fills all dict keys with random strings (for validation/testing). \n        So we can test the output in the notification body\n        \"\"\"\n        for key in self.keys():\n            if key in ['uuid', 'time', 'watch_uuid', 'change_datetime'] or key.startswith('diff'):\n                continue\n            rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12))\n            self[key] = rand_str\n\n    def __setitem__(self, key, value):\n        if key == 'notification_format' and isinstance(value, str) and not value.startswith('RANDOM-PLACEHOLDER-'):\n            if not valid_notification_formats.get(value):\n                raise ValueError(f'Invalid notification format: \"{value}\"')\n\n        super().__setitem__(key, value)\n\ndef add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):\n    \"\"\"\n    Efficiently renders only the diff placeholders that are actually used in the notification text.\n\n    Scans the notification template for diff placeholder usage (diff, diff_added, diff_clean, etc.)\n    and only renders those specific variants, avoiding expensive render_diff() calls for unused placeholders.\n    Uses LRU caching to avoid duplicate renders when multiple placeholders share the same arguments.\n\n    Args:\n        notification_scan_text: The notification template text to scan for placeholders\n        prev_snapshot: Previous version of content for diff comparison\n        current_snapshot: Current version of content for diff comparison\n        word_diff: Whether to use word-level (True) or line-level (False) diffing\n\n    Returns:\n        dict: Only the diff placeholders that were found in notification_scan_text, with rendered content\n    \"\"\"\n    import re\n\n    now = time.time()\n\n    # Define base kwargs for each diff variant — these become the stored defaults\n    # on the FormattableDiff object, so {{ diff(lines=5) }} overrides on top of them\n    diff_specs = {\n        'diff': {'word_diff': word_diff},\n        'diff_clean': {'word_diff': word_diff, 'include_change_type_prefix': False},\n        'diff_added': {'word_diff': word_diff, 'include_removed': False},\n        'diff_added_clean': {'word_diff': word_diff, 'include_removed': False, 'include_change_type_prefix': False},\n        'diff_full': {'word_diff': word_diff, 'include_equal': True},\n        'diff_full_clean': {'word_diff': word_diff, 'include_equal': True, 'include_change_type_prefix': False},\n        'diff_patch': {'word_diff': word_diff, 'patch_format': True},\n        'diff_removed': {'word_diff': word_diff, 'include_added': False},\n        'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},\n    }\n\n    ret = {}\n    rendered_count = 0\n    # Only create FormattableDiff objects for diff keys actually used in the notification text\n    for key in NotificationContextData().keys():\n        if key.startswith('diff') and key in diff_specs:\n            # Check if this placeholder is actually used in the notification text\n            pattern = rf\"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])\"\n            if re.search(pattern, notification_scan_text, re.IGNORECASE):\n                ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])\n                rendered_count += 1\n\n    if rendered_count:\n        logger.trace(f\"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s\")\n\n    return ret\n\ndef set_basic_notification_vars(current_snapshot, prev_snapshot, watch, triggered_text, timestamp_changed=None):\n\n    n_object = {\n        'current_snapshot': current_snapshot,\n        'prev_snapshot': prev_snapshot,\n        'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None,\n        'change_datetime': FormattableTimestamp(timestamp_changed) if timestamp_changed else None,\n        'triggered_text': triggered_text,\n        'uuid': watch.get('uuid') if watch else None,\n        'watch_url': watch.get('url') if watch else None,\n        'watch_uuid': watch.get('uuid') if watch else None,\n        'watch_mime_type': watch.get('content-type')\n    }\n\n    # The \\n's in the content from the above will get converted to <br> etc depending on the notification format\n\n    if watch:\n        n_object.update(watch.extra_notification_token_values())\n\n    return n_object\n\nclass NotificationService:\n    \"\"\"\n    Standalone notification service that handles all notification functionality\n    previously embedded in the update_worker class\n    \"\"\"\n    \n    def __init__(self, datastore, notification_q):\n        self.datastore = datastore\n        self.notification_q = notification_q\n    \n    def queue_notification_for_watch(self, n_object: NotificationContextData, watch, date_index_from=-2, date_index_to=-1):\n        \"\"\"\n        Queue a notification for a watch with full diff rendering and template variables\n        \"\"\"\n        from changedetectionio.notification import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n\n        if not isinstance(n_object, NotificationContextData):\n            raise TypeError(f\"Expected NotificationContextData, got {type(n_object)}\")\n\n        dates = []\n        trigger_text = ''\n\n        if watch:\n            watch_history = watch.history\n            dates = list(watch_history.keys())\n            trigger_text = watch.get('trigger_text', [])\n\n        # Add text that was triggered\n        if len(dates):\n            snapshot_contents = watch.get_history_snapshot(timestamp=dates[-1])\n        else:\n            snapshot_contents = \"No snapshot/history available, the watch should fetch atleast once.\"\n\n        # If we ended up here with \"System default\"\n        if n_object.get('notification_format') == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:\n            n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format')\n\n\n        triggered_text = ''\n        if len(trigger_text):\n            from . import html_tools\n            triggered_text = html_tools.get_triggered_text(content=snapshot_contents, trigger_text=trigger_text)\n            if triggered_text:\n                triggered_text = '\\n'.join(triggered_text)\n\n        # Could be called as a 'test notification' with only 1 snapshot available\n        prev_snapshot = \"Example text: example test\\nExample text: change detection is cool\\nExample text: some more examples\\n\"\n        current_snapshot = \"Example text: example test\\nExample text: change detection is fantastic\\nExample text: even more examples\\nExample text: a lot more examples\"\n\n        if len(dates) > 1:\n            prev_snapshot = watch.get_history_snapshot(timestamp=dates[date_index_from])\n            current_snapshot = watch.get_history_snapshot(timestamp=dates[date_index_to])\n\n\n        n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot,\n                                                    prev_snapshot=prev_snapshot,\n                                                    watch=watch,\n                                                    triggered_text=triggered_text,\n                                                    timestamp_changed=dates[date_index_to]))\n\n        if self.notification_q:\n            logger.debug(\"Queued notification for sending\")\n            self.notification_q.put(n_object)\n        else:\n            logger.debug(\"Not queued, no queue defined. Just returning processed data\")\n            return n_object\n\n    def send_content_changed_notification(self, watch_uuid):\n        \"\"\"\n        Send notification when content changes are detected\n        \"\"\"\n        n_object = NotificationContextData()\n        watch = self.datastore.data['watching'].get(watch_uuid)\n        if not watch:\n            return\n\n        watch_history = watch.history\n        dates = list(watch_history.keys())\n        # Theoretically it's possible that this could be just 1 long,\n        # - In the case that the timestamp key was not unique\n        if len(dates) == 1:\n            raise ValueError(\n                \"History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?\"\n            )\n\n        # Should be a better parent getter in the model object\n\n        # Prefer - Individual watch settings > Tag settings >  Global settings (in that order)\n        # this change probably not needed?\n        n_object['notification_urls'] = _check_cascading_vars(self.datastore, 'notification_urls', watch)\n        n_object['notification_title'] = _check_cascading_vars(self.datastore,'notification_title', watch)\n        n_object['notification_body'] = _check_cascading_vars(self.datastore,'notification_body', watch)\n        n_object['notification_format'] = _check_cascading_vars(self.datastore,'notification_format', watch)\n\n        # (Individual watch) Only prepare to notify if the rules above matched\n        queued = False\n        if n_object and n_object.get('notification_urls'):\n            queued = True\n\n            count = watch.get('notification_alert_count', 0) + 1\n            self.datastore.update_watch(uuid=watch_uuid, update_obj={'notification_alert_count': count})\n\n            self.queue_notification_for_watch(n_object=n_object, watch=watch)\n\n        return queued\n\n    def send_filter_failure_notification(self, watch_uuid):\n        \"\"\"\n        Send notification when CSS/XPath filters fail consecutively\n        \"\"\"\n        threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')\n        watch = self.datastore.data['watching'].get(watch_uuid)\n        if not watch:\n            return\n\n        filter_list = \", \".join(watch['include_filters'])\n        # @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed\n        body = f\"\"\"Hello,\n\nYour configured CSS/xPath filters of '{filter_list}' for {{{{watch_url}}}} did not appear on the page after {threshold} attempts.\n\nIt's possible the page changed layout and the filter needs updating ( Try the 'Visual Selector' tab )\n\nEdit link: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}\n\nThanks - Your omniscient changedetection.io installation.\n\"\"\"\n\n        n_object = NotificationContextData({\n            'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page',\n            'notification_body': body,\n            'notification_format': _check_cascading_vars(self.datastore, 'notification_format', watch),\n        })\n        n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')\n\n        if len(watch['notification_urls']):\n            n_object['notification_urls'] = watch['notification_urls']\n\n        elif len(self.datastore.data['settings']['application']['notification_urls']):\n            n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls']\n\n        # Only prepare to notify if the rules above matched\n        if 'notification_urls' in n_object:\n            n_object.update({\n                'watch_url': watch['url'],\n                'uuid': watch_uuid,\n                'screenshot': None\n            })\n            self.notification_q.put(n_object)\n            logger.debug(f\"Sent filter not found notification for {watch_uuid}\")\n        else:\n            logger.debug(f\"NOT sending filter not found notification for {watch_uuid} - no notification URLs\")\n\n    def send_step_failure_notification(self, watch_uuid, step_n):\n        \"\"\"\n        Send notification when browser steps fail consecutively\n        \"\"\"\n        watch = self.datastore.data['watching'].get(watch_uuid, False)\n        if not watch:\n            return\n        threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')\n\n        step = step_n + 1\n        # @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed\n\n        # {{{{ }}}} because this will be Jinja2 {{ }} tokens\n        body = f\"\"\"Hello,\n        \nYour configured browser step at position {step} for the web page watch {{{{watch_url}}}} did not appear on the page after {threshold} attempts, did the page change layout?\n\nThe element may have moved and needs editing, or does it need a delay added?\n\nEdit link: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}\n\nThanks - Your omniscient changedetection.io installation.\n\"\"\"\n\n        n_object = NotificationContextData({\n            'notification_title': f\"Changedetection.io - Alert - Browser step at position {step} could not be run\",\n            'notification_body': body,\n            'notification_format': self._check_cascading_vars('notification_format', watch),\n        })\n        n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')\n\n        if len(watch['notification_urls']):\n            n_object['notification_urls'] = watch['notification_urls']\n\n        elif len(self.datastore.data['settings']['application']['notification_urls']):\n            n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls']\n\n        # Only prepare to notify if the rules above matched\n        if 'notification_urls' in n_object:\n            n_object.update({\n                'watch_url': watch['url'],\n                'uuid': watch_uuid\n            })\n            self.notification_q.put(n_object)\n            logger.error(f\"Sent step not found notification for {watch_uuid}\")\n\n\n# Convenience functions for creating notification service instances\ndef create_notification_service(datastore, notification_q):\n    \"\"\"\n    Factory function to create a NotificationService instance\n    \"\"\"\n    return NotificationService(datastore, notification_q)"
  },
  {
    "path": "changedetectionio/pluggy_interface.py",
    "content": "import pluggy\nimport os\nimport importlib\nimport sys\nfrom loguru import logger\n\n# Global plugin namespace for changedetection.io\nPLUGIN_NAMESPACE = \"changedetectionio\"\n\nhookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE)\nhookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE)\n\n\nclass ChangeDetectionSpec:\n    \"\"\"Hook specifications for extending changedetection.io functionality.\"\"\"\n\n    @hookspec\n    def ui_edit_stats_extras(watch):\n        \"\"\"Return HTML content to add to the stats tab in the edit view.\n\n        Args:\n            watch: The watch object being edited\n\n        Returns:\n            str: HTML content to be inserted in the stats tab\n        \"\"\"\n        pass\n\n    @hookspec\n    def register_content_fetcher(self):\n        \"\"\"Return a tuple of (fetcher_name, fetcher_class) for content fetcher plugins.\n\n        The fetcher_name should start with 'html_' and the fetcher_class\n        should inherit from changedetectionio.content_fetchers.base.Fetcher\n\n        Returns:\n            tuple: (str: fetcher_name, class: fetcher_class)\n        \"\"\"\n        pass\n\n    @hookspec\n    def fetcher_status_icon(fetcher_name):\n        \"\"\"Return status icon HTML attributes for a content fetcher.\n\n        Args:\n            fetcher_name: The name of the fetcher (e.g., 'html_webdriver', 'html_js_zyte')\n\n        Returns:\n            str: HTML string containing <img> tags or other status icon elements\n                 Empty string if no custom status icon is needed\n        \"\"\"\n        pass\n\n    @hookspec\n    def plugin_static_path(self):\n        \"\"\"Return the path to the plugin's static files directory.\n\n        Returns:\n            str: Absolute path to the plugin's static directory, or None if no static files\n        \"\"\"\n        pass\n\n    @hookspec\n    def get_itemprop_availability_override(self, content, fetcher_name, fetcher_instance, url):\n        \"\"\"Provide custom implementation of get_itemprop_availability for a specific fetcher.\n\n        This hook allows plugins to provide their own product availability detection\n        when their fetcher is being used. This is called as a fallback when the built-in\n        method doesn't find good data.\n\n        Args:\n            content: The HTML/text content to parse\n            fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')\n            fetcher_instance: The fetcher instance that generated the content\n            url: The URL being watched/checked\n\n        Returns:\n            dict or None: Dictionary with availability data:\n                {\n                    'price': float or None,\n                    'availability': str or None,  # e.g., 'in stock', 'out of stock'\n                    'currency': str or None,      # e.g., 'USD', 'EUR'\n                }\n                Or None if this plugin doesn't handle this fetcher or couldn't extract data\n        \"\"\"\n        pass\n\n    @hookspec\n    def plugin_settings_tab(self):\n        \"\"\"Return settings tab information for this plugin.\n\n        This hook allows plugins to add their own settings tab to the settings page.\n        Settings will be saved to a separate JSON file in the datastore directory.\n\n        Returns:\n            dict or None: Dictionary with settings tab information:\n                {\n                    'plugin_id': str,           # Unique identifier (e.g., 'zyte_fetcher')\n                    'tab_label': str,           # Display name for tab (e.g., 'Zyte Fetcher')\n                    'form_class': Form,         # WTForms Form class for the settings\n                    'template_path': str,       # Optional: path to Jinja2 template (relative to plugin)\n                                                # If not provided, a default form renderer will be used\n                }\n                Or None if this plugin doesn't provide settings\n        \"\"\"\n        pass\n\n    @hookspec\n    def register_processor(self):\n        \"\"\"Register an external processor plugin.\n\n        External packages can implement this hook to register custom processors\n        that will be discovered alongside built-in processors.\n\n        Returns:\n            dict or None: Dictionary with processor information:\n                {\n                    'processor_name': str,      # Machine name (e.g., 'osint_recon')\n                    'processor_module': module, # Module containing processor.py\n                    'processor_class': class,   # The perform_site_check class\n                    'metadata': {               # Optional metadata\n                        'name': str,            # Display name\n                        'description': str,     # Description\n                        'processor_weight': int,# Sort weight (lower = higher priority)\n                        'list_badge_text': str, # Badge text for UI\n                    }\n                }\n                Return None if this plugin doesn't provide a processor\n        \"\"\"\n        pass\n\n    @hookspec\n    def update_handler_alter(update_handler, watch, datastore):\n        \"\"\"Modify or wrap the update_handler before it processes a watch.\n\n        This hook is called after the update_handler (perform_site_check instance) is created\n        but before it calls call_browser() and run_changedetection(). Plugins can use this to:\n        - Wrap the handler to add logging/metrics\n        - Modify handler configuration\n        - Add custom preprocessing logic\n\n        Args:\n            update_handler: The perform_site_check instance that will process the watch\n            watch: The watch dict being processed\n            datastore: The application datastore\n\n        Returns:\n            object or None: Return a modified/wrapped handler, or None to keep the original.\n                           If multiple plugins return handlers, they are chained in registration order.\n        \"\"\"\n        pass\n\n    @hookspec\n    def update_finalize(update_handler, watch, datastore, processing_exception):\n        \"\"\"Called after watch processing completes (success or failure).\n\n        This hook is called in the finally block after all processing is complete,\n        allowing plugins to perform cleanup, update metrics, or log final status.\n\n        The plugin can access update_handler.last_logging_insert_id if it was stored\n        during update_handler_alter, and use processing_exception to determine if\n        the processing succeeded or failed.\n\n        Args:\n            update_handler: The perform_site_check instance (may be None if creation failed)\n            watch: The watch dict that was processed (may be None if not loaded)\n            datastore: The application datastore\n            processing_exception: The exception from the main processing block, or None if successful.\n                                 This does NOT include cleanup exceptions - only exceptions from\n                                 the actual watch processing (fetch, diff, etc).\n\n        Returns:\n            None: This hook doesn't return a value\n        \"\"\"\n        pass\n\n\n# Set up Plugin Manager\nplugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)\n\n# Register hookspecs\nplugin_manager.add_hookspecs(ChangeDetectionSpec)\n\n# Load plugins from subdirectories\ndef load_plugins_from_directories():\n    # Dictionary of directories to scan for plugins\n    plugin_dirs = {\n        'conditions': os.path.join(os.path.dirname(__file__), 'conditions', 'plugins'),\n        # Add more plugin directories here as needed\n    }\n    \n    # Note: Removed the direct import of example_word_count_plugin as it's now in the conditions/plugins directory\n    \n    for dir_name, dir_path in plugin_dirs.items():\n        if not os.path.exists(dir_path):\n            continue\n            \n        # Get all Python files (excluding __init__.py)\n        for filename in os.listdir(dir_path):\n            if filename.endswith(\".py\") and filename != \"__init__.py\":\n                module_name = filename[:-3]  # Remove .py extension\n                module_path = f\"changedetectionio.{dir_name}.plugins.{module_name}\"\n                \n                try:\n                    module = importlib.import_module(module_path)\n                    # Register the plugin with pluggy\n                    plugin_manager.register(module, module_name)\n                except (ImportError, AttributeError) as e:\n                    print(f\"Error loading plugin {module_name}: {e}\")\n\n# Load plugins\nload_plugins_from_directories()\n\n# Discover installed plugins from external packages (if any)\nplugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE)\n\n# Function to inject datastore into plugins that need it\ndef inject_datastore_into_plugins(datastore):\n    \"\"\"Inject the global datastore into plugins that need access to settings.\n\n    This should be called after plugins are loaded and datastore is initialized.\n\n    Args:\n        datastore: The global ChangeDetectionStore instance\n    \"\"\"\n    for plugin_name, plugin_obj in plugin_manager.list_name_plugin():\n        # Check if plugin has datastore attribute and it's not set\n        if hasattr(plugin_obj, 'datastore'):\n            if plugin_obj.datastore is None:\n                plugin_obj.datastore = datastore\n                logger.debug(f\"Injected datastore into plugin: {plugin_name}\")\n\n# Function to register built-in fetchers - called later from content_fetchers/__init__.py\ndef register_builtin_fetchers():\n    \"\"\"Register built-in content fetchers as internal plugins\n\n    This is called from content_fetchers/__init__.py after all fetchers are imported\n    to avoid circular import issues.\n    \"\"\"\n    from changedetectionio.content_fetchers import requests, playwright, puppeteer, webdriver_selenium\n\n    # Register each built-in fetcher plugin\n    if hasattr(requests, 'requests_plugin'):\n        plugin_manager.register(requests.requests_plugin, 'builtin_requests')\n\n    if hasattr(playwright, 'playwright_plugin'):\n        plugin_manager.register(playwright.playwright_plugin, 'builtin_playwright')\n\n    if hasattr(puppeteer, 'puppeteer_plugin'):\n        plugin_manager.register(puppeteer.puppeteer_plugin, 'builtin_puppeteer')\n\n    if hasattr(webdriver_selenium, 'webdriver_selenium_plugin'):\n        plugin_manager.register(webdriver_selenium.webdriver_selenium_plugin, 'builtin_webdriver_selenium')\n\n# Helper function to collect UI stats extras from all plugins\ndef collect_ui_edit_stats_extras(watch):\n    \"\"\"Collect and combine HTML content from all plugins that implement ui_edit_stats_extras\"\"\"\n    extras_content = []\n\n    # Get all plugins that implement the ui_edit_stats_extras hook\n    results = plugin_manager.hook.ui_edit_stats_extras(watch=watch)\n\n    # If we have results, add them to our content\n    if results:\n        for result in results:\n            if result:  # Skip empty results\n                extras_content.append(result)\n\n    return \"\\n\".join(extras_content) if extras_content else \"\"\n\ndef collect_fetcher_status_icons(fetcher_name):\n    \"\"\"Collect status icon data from all plugins\n\n    Args:\n        fetcher_name: The name of the fetcher (e.g., 'html_webdriver', 'html_js_zyte')\n\n    Returns:\n        dict or None: Icon data dictionary from first matching plugin, or None\n    \"\"\"\n    # Get status icon data from plugins\n    results = plugin_manager.hook.fetcher_status_icon(fetcher_name=fetcher_name)\n\n    # Return first non-None result\n    if results:\n        for result in results:\n            if result and isinstance(result, dict):\n                return result\n\n    return None\n\ndef get_itemprop_availability_from_plugin(content, fetcher_name, fetcher_instance, url):\n    \"\"\"Get itemprop availability data from plugins as a fallback.\n\n    This is called when the built-in get_itemprop_availability doesn't find good data.\n\n    Args:\n        content: The HTML/text content to parse\n        fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')\n        fetcher_instance: The fetcher instance that generated the content\n        url: The URL being watched (watch.link - includes Jinja2 evaluation)\n\n    Returns:\n        dict or None: Availability data dictionary from first matching plugin, or None\n    \"\"\"\n    # Get availability data from plugins\n    results = plugin_manager.hook.get_itemprop_availability_override(\n        content=content,\n        fetcher_name=fetcher_name,\n        fetcher_instance=fetcher_instance,\n        url=url\n    )\n\n    # Return first non-None result with actual data\n    if results:\n        for result in results:\n            if result and isinstance(result, dict):\n                # Check if the result has any meaningful data\n                if result.get('price') is not None or result.get('availability'):\n                    return result\n\n    return None\n\n\ndef get_active_plugins():\n    \"\"\"Get a list of active plugins with their descriptions.\n\n    Returns:\n        list: List of dictionaries with plugin information:\n            [\n                {'name': 'plugin_name', 'description': 'Plugin description'},\n                ...\n            ]\n    \"\"\"\n    active_plugins = []\n\n    # Get all registered plugins\n    for plugin_name, plugin_obj in plugin_manager.list_name_plugin():\n        # Skip built-in plugins (they start with 'builtin_')\n        if plugin_name.startswith('builtin_'):\n            continue\n\n        # Get plugin description if available\n        description = None\n        if hasattr(plugin_obj, '__doc__') and plugin_obj.__doc__:\n            description = plugin_obj.__doc__.strip().split('\\n')[0]  # First line only\n        elif hasattr(plugin_obj, 'description'):\n            description = plugin_obj.description\n\n        # Try to get a friendly name from the plugin\n        friendly_name = plugin_name\n        if hasattr(plugin_obj, 'name'):\n            friendly_name = plugin_obj.name\n\n        active_plugins.append({\n            'name': friendly_name,\n            'description': description or 'No description available'\n        })\n\n    return active_plugins\n\n\ndef get_fetcher_capabilities(watch, datastore):\n    \"\"\"Get capability flags for a watch's fetcher.\n\n    Args:\n        watch: The watch object/dict\n        datastore: The datastore to resolve 'system' fetcher\n\n    Returns:\n        dict: Dictionary with capability flags:\n            {\n                'supports_browser_steps': bool,\n                'supports_screenshots': bool,\n                'supports_xpath_element_data': bool\n            }\n    \"\"\"\n    # Get the fetcher name from watch\n    fetcher_name = watch.get('fetch_backend', 'system')\n\n    # Resolve 'system' to actual fetcher\n    if fetcher_name == 'system':\n        fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests')\n\n    # Get the fetcher class\n    from changedetectionio import content_fetchers\n\n    # Try to get from built-in fetchers first\n    if hasattr(content_fetchers, fetcher_name):\n        fetcher_class = getattr(content_fetchers, fetcher_name)\n        return {\n            'supports_browser_steps': getattr(fetcher_class, 'supports_browser_steps', False),\n            'supports_screenshots': getattr(fetcher_class, 'supports_screenshots', False),\n            'supports_xpath_element_data': getattr(fetcher_class, 'supports_xpath_element_data', False)\n        }\n\n    # Try to get from plugin-provided fetchers\n    # Query all plugins for registered fetchers\n    plugin_fetchers = plugin_manager.hook.register_content_fetcher()\n    for fetcher_registration in plugin_fetchers:\n        if fetcher_registration:\n            name, fetcher_class = fetcher_registration\n            if name == fetcher_name:\n                return {\n                    'supports_browser_steps': getattr(fetcher_class, 'supports_browser_steps', False),\n                    'supports_screenshots': getattr(fetcher_class, 'supports_screenshots', False),\n                    'supports_xpath_element_data': getattr(fetcher_class, 'supports_xpath_element_data', False)\n                }\n\n    # Default: no capabilities\n    return {\n        'supports_browser_steps': False,\n        'supports_screenshots': False,\n        'supports_xpath_element_data': False\n    }\n\n\ndef get_plugin_settings_tabs():\n    \"\"\"Get all plugin settings tabs.\n\n    Returns:\n        list: List of dictionaries with plugin settings tab information:\n            [\n                {\n                    'plugin_id': str,\n                    'tab_label': str,\n                    'form_class': Form,\n                    'description': str\n                },\n                ...\n            ]\n    \"\"\"\n    tabs = []\n    results = plugin_manager.hook.plugin_settings_tab()\n\n    for result in results:\n        if result and isinstance(result, dict):\n            # Validate required fields\n            if 'plugin_id' in result and 'tab_label' in result and 'form_class' in result:\n                tabs.append(result)\n            else:\n                logger.warning(f\"Invalid plugin settings tab spec: {result}\")\n\n    return tabs\n\n\ndef load_plugin_settings(datastore_path, plugin_id):\n    \"\"\"Load settings for a specific plugin from JSON file.\n\n    Args:\n        datastore_path: Path to the datastore directory\n        plugin_id: Unique identifier for the plugin (e.g., 'zyte_fetcher')\n\n    Returns:\n        dict: Plugin settings, or empty dict if file doesn't exist\n    \"\"\"\n    import json\n    settings_file = os.path.join(datastore_path, f\"{plugin_id}.json\")\n\n    if not os.path.exists(settings_file):\n        return {}\n\n    try:\n        with open(settings_file, 'r', encoding='utf-8') as f:\n            return json.load(f)\n    except Exception as e:\n        logger.error(f\"Failed to load settings for plugin '{plugin_id}': {e}\")\n        return {}\n\n\ndef save_plugin_settings(datastore_path, plugin_id, settings):\n    \"\"\"Save settings for a specific plugin to JSON file.\n\n    Args:\n        datastore_path: Path to the datastore directory\n        plugin_id: Unique identifier for the plugin (e.g., 'zyte_fetcher')\n        settings: Dictionary of settings to save\n\n    Returns:\n        bool: True if save was successful, False otherwise\n    \"\"\"\n    import json\n    settings_file = os.path.join(datastore_path, f\"{plugin_id}.json\")\n\n    try:\n        with open(settings_file, 'w', encoding='utf-8') as f:\n            json.dump(settings, f, indent=2, ensure_ascii=False)\n        logger.info(f\"Saved settings for plugin '{plugin_id}' to {settings_file}\")\n        return True\n    except Exception as e:\n        logger.error(f\"Failed to save settings for plugin '{plugin_id}': {e}\")\n        return False\n\n\ndef get_plugin_template_paths():\n    \"\"\"Get list of plugin template directories for Jinja2 loader.\n\n    Scans both external pluggy plugins and built-in processor plugins.\n\n    Returns:\n        list: List of absolute paths to plugin template directories\n    \"\"\"\n    template_paths = []\n\n    # Add the base processors/templates directory (as absolute path)\n    processors_templates_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'processors', 'templates')\n    if os.path.isdir(processors_templates_dir):\n        template_paths.append(processors_templates_dir)\n        logger.debug(f\"Added base processors template path: {processors_templates_dir}\")\n\n    # Scan built-in processor plugins\n    from changedetectionio.processors import find_processors\n    processor_list = find_processors()\n    for processor_module, processor_name in processor_list:\n        # Each processor is a module, check if it has a templates directory\n        if hasattr(processor_module, '__file__'):\n            processor_file = processor_module.__file__\n            if processor_file:\n                # Get the processor directory (e.g., processors/image_ssim_diff/)\n                processor_dir = os.path.dirname(os.path.abspath(processor_file))\n                templates_dir = os.path.join(processor_dir, 'templates')\n                if os.path.isdir(templates_dir):\n                    template_paths.append(templates_dir)\n                    logger.debug(f\"Added processor template path: {templates_dir}\")\n\n    # Get all registered external pluggy plugins\n    for plugin_name, plugin_obj in plugin_manager.list_name_plugin():\n        # Check if plugin has a templates directory\n        if hasattr(plugin_obj, '__file__'):\n            plugin_file = plugin_obj.__file__\n        elif hasattr(plugin_obj, '__module__'):\n            # Get the module file\n            module = sys.modules.get(plugin_obj.__module__)\n            if module and hasattr(module, '__file__'):\n                plugin_file = module.__file__\n            else:\n                continue\n        else:\n            continue\n\n        if plugin_file:\n            plugin_dir = os.path.dirname(os.path.abspath(plugin_file))\n            templates_dir = os.path.join(plugin_dir, 'templates')\n            if os.path.isdir(templates_dir):\n                template_paths.append(templates_dir)\n                logger.debug(f\"Added plugin template path: {templates_dir}\")\n\n    return template_paths\n\n\ndef apply_update_handler_alter(update_handler, watch, datastore):\n    \"\"\"Apply update_handler_alter hooks from all plugins.\n\n    Allows plugins to wrap or modify the update_handler before it processes a watch.\n    Multiple plugins can chain modifications - each plugin receives the result from\n    the previous plugin.\n\n    Args:\n        update_handler: The perform_site_check instance to potentially modify\n        watch: The watch dict being processed\n        datastore: The application datastore\n\n    Returns:\n        object: The (potentially modified/wrapped) update_handler\n    \"\"\"\n    # Get all plugins that implement the update_handler_alter hook\n    results = plugin_manager.hook.update_handler_alter(\n        update_handler=update_handler,\n        watch=watch,\n        datastore=datastore\n    )\n\n    # Chain results - each plugin gets the result from the previous one\n    current_handler = update_handler\n    if results:\n        for result in results:\n            if result is not None:\n                logger.debug(f\"Plugin modified update_handler for watch {watch.get('uuid')}\")\n                current_handler = result\n\n    return current_handler\n\n\ndef apply_update_finalize(update_handler, watch, datastore, processing_exception):\n    \"\"\"Apply update_finalize hooks from all plugins.\n\n    Called in the finally block after watch processing completes, allowing plugins\n    to perform cleanup, update metrics, or log final status.\n\n    Args:\n        update_handler: The perform_site_check instance (may be None)\n        watch: The watch dict that was processed (may be None)\n        datastore: The application datastore\n        processing_exception: The exception from processing, or None if successful\n\n    Returns:\n        None\n    \"\"\"\n    try:\n        # Call all plugins that implement the update_finalize hook\n        plugin_manager.hook.update_finalize(\n            update_handler=update_handler,\n            watch=watch,\n            datastore=datastore,\n            processing_exception=processing_exception\n        )\n    except Exception as e:\n        # Don't let plugin errors crash the worker\n        logger.error(f\"Error in update_finalize hook: {e}\")\n        logger.exception(f\"update_finalize hook exception details:\")"
  },
  {
    "path": "changedetectionio/processors/README.md",
    "content": "# Change detection post-processors\n\nThe concept here is to be able to switch between different domain specific problems to solve.\n\n- `text_json_diff` The traditional text and JSON comparison handler\n- `restock_diff` Only cares about detecting if a product looks like it has some text that suggests that it's out of stock, otherwise assumes that it's in stock.\n\nSome suggestions for the future\n\n- `graphical` \n\n## API schema extension (`api.yaml`)\n\nA processor can extend the Watch/Tag API schema by placing an `api.yaml` alongside its `__init__.py`.\nDefine a `components.schemas.processor_config_<name>` entry and it will be merged into `WatchBase` at startup,\nmaking `processor_config_<name>` a valid field on all watch create/update API calls.\nThe fully merged spec is served live at `/api/v1/full-spec`.\n\nSee `restock_diff/api.yaml` for a working example.\n\n## Todo\n\n- Make each processor return a extra list of sub-processed (so you could configure a single processor in different ways)\n- move restock_diff to its own pip/github repo\n"
  },
  {
    "path": "changedetectionio/processors/__init__.py",
    "content": "from functools import lru_cache\nfrom loguru import logger\nfrom flask_babel import gettext, get_locale\nimport importlib\nimport inspect\nimport os\nimport pkgutil\n\ndef find_sub_packages(package_name):\n    \"\"\"\n    Find all sub-packages within the given package.\n\n    :param package_name: The name of the base package to scan for sub-packages.\n    :return: A list of sub-package names.\n    \"\"\"\n    package = importlib.import_module(package_name)\n    return [name for _, name, is_pkg in pkgutil.iter_modules(package.__path__) if is_pkg]\n\n\n@lru_cache(maxsize=1)\ndef find_processors():\n    \"\"\"\n    Find all subclasses of DifferenceDetectionProcessor in the specified package.\n    Results are cached to avoid repeated discovery.\n\n    :param package_name: The name of the package to scan for processor modules.\n    :return: A list of (module, class) tuples.\n    \"\"\"\n    package_name = \"changedetectionio.processors\"  # Name of the current package/module\n\n    processors = []\n    sub_packages = find_sub_packages(package_name)\n    from changedetectionio.processors.base import difference_detection_processor\n\n    for sub_package in sub_packages:\n        module_name = f\"{package_name}.{sub_package}.processor\"\n        try:\n            module = importlib.import_module(module_name)\n\n            # Iterate through all classes in the module\n            for name, obj in inspect.getmembers(module, inspect.isclass):\n                # Only register classes that are actually defined in this module (not imported)\n                if (issubclass(obj, difference_detection_processor) and\n                    obj is not difference_detection_processor and\n                    obj.__module__ == module.__name__):\n                    processors.append((module, sub_package))\n                    break  # Only need one processor per module\n        except (ModuleNotFoundError, ImportError) as e:\n            logger.warning(f\"Failed to import module {module_name}: {e} (find_processors())\")\n\n    # Discover plugin processors via pluggy\n    try:\n        from changedetectionio.pluggy_interface import plugin_manager\n        plugin_results = plugin_manager.hook.register_processor()\n\n        for result in plugin_results:\n            if result and isinstance(result, dict):\n                processor_module = result.get('processor_module')\n                processor_name = result.get('processor_name')\n\n                if processor_module and processor_name:\n                    processors.append((processor_module, processor_name))\n                    plugin_path = getattr(processor_module, '__file__', 'unknown location')\n                    logger.info(f\"Registered plugin processor: {processor_name} from {plugin_path}\")\n    except Exception as e:\n        logger.warning(f\"Error loading plugin processors: {e}\")\n\n    return processors\n\n\ndef get_parent_module(module):\n    module_name = module.__name__\n    if '.' not in module_name:\n        return None  # Top-level module has no parent\n    parent_module_name = module_name.rsplit('.', 1)[0]\n    try:\n        return importlib.import_module(parent_module_name)\n    except Exception as e:\n        pass\n\n    return False\n\n\n\ndef get_custom_watch_obj_for_processor(processor_name):\n    from changedetectionio.model import Watch\n    watch_class = Watch.model\n    processor_classes = find_processors()\n    custom_watch_obj = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)\n    if custom_watch_obj:\n        # Parent of .processor.py COULD have its own Watch implementation\n        parent_module = get_parent_module(custom_watch_obj[0])\n        if hasattr(parent_module, 'Watch'):\n            watch_class = parent_module.Watch\n\n    return watch_class\n\n\ndef find_processor_module(processor_name):\n    \"\"\"\n    Find the processor module by name.\n\n    Args:\n        processor_name: Processor machine name (e.g., 'image_ssim_diff')\n\n    Returns:\n        module: The processor's parent module, or None if not found\n    \"\"\"\n    processor_classes = find_processors()\n    processor_tuple = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)\n\n    if processor_tuple:\n        # Return the parent module (the package containing processor.py)\n        return get_parent_module(processor_tuple[0])\n\n    return None\n\n\ndef get_processor_module(processor_name):\n    \"\"\"\n    Get the actual processor module (with perform_site_check class) by name.\n    Works for both built-in and plugin processors.\n\n    Args:\n        processor_name: Processor machine name (e.g., 'text_json_diff', 'osint_recon')\n\n    Returns:\n        module: The processor module containing perform_site_check, or None if not found\n    \"\"\"\n    processor_classes = find_processors()\n    processor_tuple = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)\n\n    if processor_tuple:\n        # Return the actual processor module (first element of tuple)\n        return processor_tuple[0]\n\n    return None\n\n\ndef get_processor_submodule(processor_name, submodule_name):\n    \"\"\"\n    Get an optional submodule from a processor (e.g., 'difference', 'extract', 'preview').\n    Works for both built-in and plugin processors.\n\n    Args:\n        processor_name: Processor machine name (e.g., 'text_json_diff', 'osint_recon')\n        submodule_name: Name of the submodule (e.g., 'difference', 'extract', 'preview')\n\n    Returns:\n        module: The submodule if it exists, or None if not found\n    \"\"\"\n    processor_classes = find_processors()\n    processor_tuple = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)\n\n    if not processor_tuple:\n        return None\n\n    processor_module = processor_tuple[0]\n    parent_module = get_parent_module(processor_module)\n\n    if not parent_module:\n        return None\n\n    # Try to import the submodule\n    try:\n        # For built-in processors: changedetectionio.processors.text_json_diff.difference\n        # For plugin processors: changedetectionio_osint.difference\n        parent_module_name = parent_module.__name__\n        submodule_full_name = f\"{parent_module_name}.{submodule_name}\"\n        return importlib.import_module(submodule_full_name)\n    except (ModuleNotFoundError, ImportError):\n        return None\n\n\n@lru_cache(maxsize=1)\ndef get_plugin_processor_metadata():\n    \"\"\"Get metadata from plugin processors.\"\"\"\n    metadata = {}\n    try:\n        from changedetectionio.pluggy_interface import plugin_manager\n        plugin_results = plugin_manager.hook.register_processor()\n\n        for result in plugin_results:\n            if result and isinstance(result, dict):\n                processor_name = result.get('processor_name')\n                meta = result.get('metadata', {})\n                if processor_name:\n                    metadata[processor_name] = meta\n    except Exception as e:\n        logger.warning(f\"Error getting plugin processor metadata: {e}\")\n    return metadata\n\n@lru_cache(maxsize=32)\ndef _available_processors_cached(locale_str):\n    \"\"\"\n    Internal cached function that includes locale in cache key.\n    This ensures translations are cached per-language instead of globally.\n\n    :param locale_str: The locale string (e.g., 'en', 'it', 'zh')\n    :return: A list of tuples (processor_name, translated_description, weight)\n    \"\"\"\n    processor_classes = find_processors()\n\n    # Check if DISABLED_PROCESSORS env var is set\n    disabled_processors_env = os.getenv('DISABLED_PROCESSORS', 'image_ssim_diff').strip()\n    disabled_processors = []\n    if disabled_processors_env:\n        # Parse comma-separated list and strip whitespace\n        disabled_processors = [p.strip() for p in disabled_processors_env.split(',') if p.strip()]\n        logger.info(f\"DISABLED_PROCESSORS set, disabling: {disabled_processors}\")\n\n    available = []\n    plugin_metadata = get_plugin_processor_metadata()\n\n    for module, sub_package_name in processor_classes:\n        # Skip disabled processors\n        if sub_package_name in disabled_processors:\n            logger.debug(f\"Skipping processor '{sub_package_name}' (in DISABLED_PROCESSORS)\")\n            continue\n\n        # Check if this is a plugin processor\n        if sub_package_name in plugin_metadata:\n            meta = plugin_metadata[sub_package_name]\n            description = gettext(meta.get('name', sub_package_name))\n            # Plugin processors start from weight 10 to separate them from built-in processors\n            weight = 100 + meta.get('processor_weight', 0)\n        else:\n            # Try to get the 'name' attribute from the processor module first\n            if hasattr(module, 'name'):\n                description = gettext(module.name)\n            else:\n                # Fall back to processor_description from parent module's __init__.py\n                parent_module = get_parent_module(module)\n                if parent_module and hasattr(parent_module, 'processor_description'):\n                    description = gettext(parent_module.processor_description)\n                else:\n                    # Final fallback to a readable name\n                    description = sub_package_name.replace('_', ' ').title()\n\n            # Get weight for sorting (lower weight = higher in list)\n            weight = 0  # Default weight for processors without explicit weight\n\n            # Check processor module itself first\n            if hasattr(module, 'processor_weight'):\n                weight = module.processor_weight\n            else:\n                # Fall back to parent module (package __init__.py)\n                parent_module = get_parent_module(module)\n                if parent_module and hasattr(parent_module, 'processor_weight'):\n                    weight = parent_module.processor_weight\n\n        available.append((sub_package_name, description, weight))\n\n    # Sort by weight (lower weight = appears first)\n    available.sort(key=lambda x: x[2])\n\n    # Return as tuples without weight (for backwards compatibility)\n    return [(name, desc) for name, desc, weight in available]\n\ndef available_processors():\n    \"\"\"\n    Get a list of processors by name and description for the UI elements.\n    Can be filtered via DISABLED_PROCESSORS environment variable (comma-separated list).\n\n    This function delegates to a locale-aware cached version to ensure translations\n    are cached per-language instead of globally.\n\n    :return: A list of tuples (processor_name, translated_description)\n    \"\"\"\n    # Get current locale and use it as cache key\n    # Convert Babel Locale object to string for use as cache key\n    locale = get_locale()\n    locale_str = str(locale) if locale else 'en'\n    return _available_processors_cached(locale_str)\n\n\ndef get_default_processor():\n    \"\"\"\n    Get the default processor to use when none is specified.\n    Returns the first available processor based on weight (lowest weight = highest priority).\n    This ensures forms auto-select a valid processor even when DISABLED_PROCESSORS filters the list.\n\n    :return: The processor name string (e.g., 'text_json_diff')\n    \"\"\"\n    available = available_processors()\n    if available:\n        return available[0][0]  # Return the processor name from first tuple\n    return 'text_json_diff'  # Fallback if somehow no processors are available\n\n\ndef get_processor_badge_texts():\n    \"\"\"\n    Get a dictionary mapping processor names to their list_badge_text values.\n    Translations are applied based on the current request locale.\n\n    :return: A dict mapping processor name to badge text (e.g., {'text_json_diff': 'Text', 'restock_diff': 'Restock'})\n    \"\"\"\n    processor_classes = find_processors()\n    badge_texts = {}\n\n    for module, sub_package_name in processor_classes:\n        # Try to get the 'list_badge_text' attribute from the processor module\n        if hasattr(module, 'list_badge_text'):\n            badge_texts[sub_package_name] = gettext(module.list_badge_text)\n        else:\n            # Fall back to parent module's __init__.py\n            parent_module = get_parent_module(module)\n            if parent_module and hasattr(parent_module, 'list_badge_text'):\n                badge_texts[sub_package_name] = gettext(parent_module.list_badge_text)\n\n    return badge_texts\n\n\ndef get_processor_descriptions():\n    \"\"\"\n    Get a dictionary mapping processor names to their description/name values.\n    Translations are applied based on the current request locale.\n\n    :return: A dict mapping processor name to description (e.g., {'text_json_diff': 'Webpage Text/HTML, JSON and PDF changes'})\n    \"\"\"\n    processor_classes = find_processors()\n    descriptions = {}\n\n    for module, sub_package_name in processor_classes:\n        # Try to get the 'name' or 'description' attribute from the processor module first\n        if hasattr(module, 'name'):\n            descriptions[sub_package_name] = gettext(module.name)\n        elif hasattr(module, 'description'):\n            descriptions[sub_package_name] = gettext(module.description)\n        else:\n            # Fall back to parent module's __init__.py\n            parent_module = get_parent_module(module)\n            if parent_module and hasattr(parent_module, 'processor_description'):\n                descriptions[sub_package_name] = gettext(parent_module.processor_description)\n            elif parent_module and hasattr(parent_module, 'name'):\n                descriptions[sub_package_name] = gettext(parent_module.name)\n            else:\n                # Final fallback to a readable name\n                descriptions[sub_package_name] = sub_package_name.replace('_', ' ').title()\n\n    return descriptions\n\n\ndef generate_processor_badge_colors(processor_name):\n    \"\"\"\n    Generate consistent colors for a processor badge based on its name.\n    Uses a hash of the processor name to generate pleasing, accessible colors\n    for both light and dark modes.\n\n    :param processor_name: The processor name (e.g., 'text_json_diff')\n    :return: A dict with 'light' and 'dark' color schemes, each containing 'bg' and 'color'\n    \"\"\"\n    import hashlib\n\n    # Generate a consistent hash from the processor name\n    hash_obj = hashlib.md5(processor_name.encode('utf-8'))\n    hash_int = int(hash_obj.hexdigest()[:8], 16)\n\n    # Generate hue from hash (0-360)\n    hue = hash_int % 360\n\n    # Light mode: pastel background with darker text\n    light_saturation = 60 + (hash_int % 25)  # 60-85%\n    light_lightness = 85 + (hash_int % 10)   # 85-95% - very light\n    text_lightness = 25 + (hash_int % 15)    # 25-40% - dark\n\n    # Dark mode: solid, vibrant colors with white text\n    dark_saturation = 55 + (hash_int % 20)   # 55-75%\n    dark_lightness = 45 + (hash_int % 15)    # 45-60%\n\n    return {\n        'light': {\n            'bg': f'hsl({hue}, {light_saturation}%, {light_lightness}%)',\n            'color': f'hsl({hue}, 50%, {text_lightness}%)'\n        },\n        'dark': {\n            'bg': f'hsl({hue}, {dark_saturation}%, {dark_lightness}%)',\n            'color': '#fff'\n        }\n    }\n\n\n@lru_cache(maxsize=1)\ndef get_processor_badge_css():\n    \"\"\"\n    Generate CSS for all processor badges with auto-generated colors.\n    This creates CSS rules for both light and dark modes for each processor.\n\n    :return: A string containing CSS rules for all processor badges\n    \"\"\"\n    processor_classes = find_processors()\n    css_rules = []\n\n    for module, sub_package_name in processor_classes:\n        colors = generate_processor_badge_colors(sub_package_name)\n\n        # Light mode rule\n        css_rules.append(\n            f\".processor-badge-{sub_package_name} {{\\n\"\n            f\"  background-color: {colors['light']['bg']};\\n\"\n            f\"  color: {colors['light']['color']};\\n\"\n            f\"}}\"\n        )\n\n        # Dark mode rule\n        css_rules.append(\n            f\"html[data-darkmode=\\\"true\\\"] .processor-badge-{sub_package_name} {{\\n\"\n            f\"  background-color: {colors['dark']['bg']};\\n\"\n            f\"  color: {colors['dark']['color']};\\n\"\n            f\"}}\"\n        )\n\n    return '\\n\\n'.join(css_rules)\n\n\ndef save_processor_config(datastore, watch_uuid, config_data):\n    \"\"\"\n    Save processor-specific configuration to JSON file.\n\n    This is a shared helper function used by both the UI edit form and API endpoints\n    to consistently handle processor configuration storage.\n\n    Args:\n        datastore: The application datastore instance\n        watch_uuid: UUID of the watch\n        config_data: Dictionary of configuration data to save (with processor_config_* prefix removed)\n\n    Returns:\n        bool: True if saved successfully, False otherwise\n    \"\"\"\n    if not config_data:\n        return True\n\n    try:\n        from changedetectionio.processors.base import difference_detection_processor\n\n        # Get processor name from watch\n        watch = datastore.data['watching'].get(watch_uuid)\n        if not watch:\n            logger.error(f\"Cannot save processor config: watch {watch_uuid} not found\")\n            return False\n\n        processor_name = watch.get('processor', 'text_json_diff')\n\n        # Create a processor instance to access config methods\n        processor_instance = difference_detection_processor(datastore, watch_uuid)\n\n        # Use processor name as filename so each processor keeps its own config\n        config_filename = f'{processor_name}.json'\n        processor_instance.update_extra_watch_config(config_filename, config_data)\n\n        logger.debug(f\"Saved processor config to {config_filename}: {config_data}\")\n        return True\n\n    except Exception as e:\n        logger.error(f\"Failed to save processor config: {e}\")\n        return False\n\n\ndef extract_processor_config_from_form_data(form_data):\n    \"\"\"\n    Extract processor_config_* fields from form data and return separate dicts.\n\n    This is a shared helper function used by both the UI edit form and API endpoints\n    to consistently handle processor configuration extraction.\n\n    IMPORTANT: This function modifies form_data in-place by removing processor_config_* fields.\n\n    Args:\n        form_data: Dictionary of form data (will be modified in-place)\n\n    Returns:\n        dict: Dictionary of processor config data (with processor_config_* prefix removed)\n    \"\"\"\n    processor_config_data = {}\n\n    # Use list() to create a copy of keys since we're modifying the dict\n    for field_name in list(form_data.keys()):\n        if field_name.startswith('processor_config_'):\n            config_key = field_name.replace('processor_config_', '')\n            # Save all values (including empty strings) to allow explicit clearing of settings\n            processor_config_data[config_key] = form_data[field_name]\n            # Remove from form_data to prevent it from reaching datastore\n            del form_data[field_name]\n\n    return processor_config_data\n\n"
  },
  {
    "path": "changedetectionio/processors/base.py",
    "content": "import asyncio\nimport re\nimport hashlib\n\nfrom changedetectionio.browser_steps.browser_steps import browser_steps_get_valid_steps\nfrom changedetectionio.content_fetchers.base import Fetcher\nfrom changedetectionio.strtobool import strtobool\nfrom changedetectionio.validate_url import is_private_hostname\nfrom copy import deepcopy\nfrom abc import abstractmethod\nimport os\nfrom urllib.parse import urlparse\nfrom loguru import logger\n\nSCREENSHOT_FORMAT_JPEG = 'JPEG'\nSCREENSHOT_FORMAT_PNG = 'PNG'\n\nclass difference_detection_processor():\n    browser_steps = None\n    datastore = None\n    fetcher = None\n    screenshot = None\n    watch = None\n    xpath_data = None\n    preferred_proxy = None\n    screenshot_format = SCREENSHOT_FORMAT_JPEG\n    last_raw_content_checksum = None\n\n    def __init__(self, datastore, watch_uuid):\n        self.datastore = datastore\n        self.watch_uuid = watch_uuid\n\n        # Create a stable snapshot of the watch for processing\n        # Why deepcopy?\n        # 1. Prevents \"dict changed during iteration\" errors if watch is modified during processing\n        # 2. Preserves Watch object with properties (.link, .is_pdf, etc.) - can't use dict()\n        # 3. Safe now: Watch.__deepcopy__() shares datastore ref (no memory leak) but copies dict data\n        self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))\n\n        # Generic fetcher that should be extended (requests, playwright etc)\n        self.fetcher = Fetcher()\n\n        # Load the last raw content checksum from file\n        self.read_last_raw_content_checksum()\n\n    def update_last_raw_content_checksum(self, checksum):\n        \"\"\"\n        Save the raw content MD5 checksum to file.\n        This is used for skip logic - avoid reprocessing if raw HTML unchanged.\n        \"\"\"\n        if not checksum:\n            return\n\n        watch = self.datastore.data['watching'].get(self.watch_uuid)\n        if not watch:\n            return\n\n        data_dir = watch.data_dir\n        if not data_dir:\n            return\n\n        watch.ensure_data_dir_exists()\n        checksum_file = os.path.join(data_dir, 'last-checksum.txt')\n\n        try:\n            with open(checksum_file, 'w', encoding='utf-8') as f:\n                f.write(checksum)\n            self.last_raw_content_checksum = checksum\n        except IOError as e:\n            logger.warning(f\"Failed to write checksum file for {self.watch_uuid}: {e}\")\n\n    def read_last_raw_content_checksum(self):\n        \"\"\"\n        Read the last raw content MD5 checksum from file.\n        Returns None if file doesn't exist (first run) or can't be read.\n        \"\"\"\n        watch = self.datastore.data['watching'].get(self.watch_uuid)\n        if not watch:\n            self.last_raw_content_checksum = None\n            return\n\n        data_dir = watch.data_dir\n        if not data_dir:\n            self.last_raw_content_checksum = None\n            return\n\n        checksum_file = os.path.join(data_dir, 'last-checksum.txt')\n\n        if not os.path.isfile(checksum_file):\n            self.last_raw_content_checksum = None\n            return\n\n        try:\n            with open(checksum_file, 'r', encoding='utf-8') as f:\n                self.last_raw_content_checksum = f.read().strip()\n        except IOError as e:\n            logger.warning(f\"Failed to read checksum file for {self.watch_uuid}: {e}\")\n            self.last_raw_content_checksum = None\n\n\n    async def validate_iana_url(self):\n        \"\"\"Pre-flight SSRF check — runs DNS lookup in executor to avoid blocking the event loop.\n        Covers all fetchers (requests, playwright, puppeteer, plugins) since every fetch goes\n        through call_browser().\n        \"\"\"\n        if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):\n            return\n        parsed = urlparse(self.watch.link)\n        if not parsed.hostname:\n            return\n        loop = asyncio.get_running_loop()\n        if await loop.run_in_executor(None, is_private_hostname, parsed.hostname):\n            raise Exception(\n                f\"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address. \"\n                f\"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.\"\n            )\n\n    async def call_browser(self, preferred_proxy_id=None):\n\n        from requests.structures import CaseInsensitiveDict\n\n        url = self.watch.link\n\n        # Protect against file:, file:/, file:// access, check the real \"link\" without any meta \"source:\" etc prepended.\n        if re.search(r'^file:', url.strip(), re.IGNORECASE):\n            if not strtobool(os.getenv('ALLOW_FILE_URI', 'false')):\n                raise Exception(\n                    \"file:// type access is denied for security reasons.\"\n                )\n\n        await self.validate_iana_url()\n\n        # Requests, playwright, other browser via wss:// etc, fetch_extra_something\n        prefer_fetch_backend = self.watch.get('fetch_backend', 'system')\n\n        # Proxy ID \"key\"\n        preferred_proxy_id = preferred_proxy_id if preferred_proxy_id else self.datastore.get_preferred_proxy_for_watch(\n            uuid=self.watch.get('uuid'))\n\n        # Pluggable content self.fetcher\n        if not prefer_fetch_backend or prefer_fetch_backend == 'system':\n            prefer_fetch_backend = self.datastore.data['settings']['application'].get('fetch_backend')\n\n        # In the case that the preferred fetcher was a browser config with custom connection URL..\n        # @todo - on save watch, if its extra_browser_ then it should be obvious it will use playwright (like if its requests now..)\n        custom_browser_connection_url = None\n        if prefer_fetch_backend.startswith('extra_browser_'):\n            (t, key) = prefer_fetch_backend.split('extra_browser_')\n            connection = list(\n                filter(lambda s: (s['browser_name'] == key), self.datastore.data['settings']['requests'].get('extra_browsers', [])))\n            if connection:\n                prefer_fetch_backend = 'html_webdriver'\n                custom_browser_connection_url = connection[0].get('browser_connection_url')\n\n        # PDF should be html_requests because playwright will serve it up (so far) in a embedded page\n        # @todo https://github.com/dgtlmoon/changedetection.io/issues/2019\n        # @todo needs test to or a fix\n        if self.watch.is_pdf:\n            prefer_fetch_backend = \"html_requests\"\n\n        # Grab the right kind of 'fetcher', (playwright, requests, etc)\n        from changedetectionio import content_fetchers\n        if hasattr(content_fetchers, prefer_fetch_backend):\n            # @todo TEMPORARY HACK - SWITCH BACK TO PLAYWRIGHT FOR BROWSERSTEPS\n            if prefer_fetch_backend == 'html_webdriver' and self.watch.has_browser_steps:\n                # This is never supported in selenium anyway\n                logger.warning(\n                    \"Using playwright fetcher override for possible puppeteer request in browsersteps, because puppetteer:browser steps is incomplete.\")\n                from changedetectionio.content_fetchers.playwright import fetcher as playwright_fetcher\n                fetcher_obj = playwright_fetcher\n            else:\n                fetcher_obj = getattr(content_fetchers, prefer_fetch_backend)\n        else:\n            # What it referenced doesnt exist, Just use a default\n            fetcher_obj = getattr(content_fetchers, \"html_requests\")\n\n        proxy_url = None\n        if preferred_proxy_id:\n            # Custom browser endpoints should NOT have a proxy added\n            if not prefer_fetch_backend.startswith('extra_browser_'):\n                proxy_url = self.datastore.proxy_list.get(preferred_proxy_id).get('url')\n                logger.debug(f\"Selected proxy key '{preferred_proxy_id}' as proxy URL '{proxy_url}' for {url}\")\n            else:\n                logger.debug(\"Skipping adding proxy data when custom Browser endpoint is specified. \")\n\n        logger.debug(f\"Using proxy '{proxy_url}' for {self.watch['uuid']}\")\n\n        # Now call the fetcher (playwright/requests/etc) with arguments that only a fetcher would need.\n        # When browser_connection_url is None, it method should default to working out whats the best defaults (os env vars etc)\n        self.fetcher = fetcher_obj(proxy_override=proxy_url,\n                                   custom_browser_connection_url=custom_browser_connection_url,\n                                   screenshot_format=self.screenshot_format\n                                   )\n\n        if self.watch.has_browser_steps:\n            self.fetcher.browser_steps = browser_steps_get_valid_steps(self.watch.get('browser_steps', []))\n            self.fetcher.browser_steps_screenshot_path = os.path.join(self.datastore.datastore_path, self.watch.get('uuid'))\n\n        # Tweak the base config with the per-watch ones\n        from changedetectionio.jinja2_custom import render as jinja_render\n        request_headers = CaseInsensitiveDict()\n\n        ua = self.datastore.data['settings']['requests'].get('default_ua')\n        if ua and ua.get(prefer_fetch_backend):\n            request_headers.update({'User-Agent': ua.get(prefer_fetch_backend)})\n\n        request_headers.update(self.watch.get('headers', {}))\n        request_headers.update(self.datastore.get_all_base_headers())\n        request_headers.update(self.datastore.get_all_headers_in_textfile_for_watch(uuid=self.watch.get('uuid')))\n\n        # https://github.com/psf/requests/issues/4525\n        # Requests doesnt yet support brotli encoding, so don't put 'br' here, be totally sure that the user cannot\n        # do this by accident.\n        if 'Accept-Encoding' in request_headers and \"br\" in request_headers['Accept-Encoding']:\n            request_headers['Accept-Encoding'] = request_headers['Accept-Encoding'].replace(', br', '')\n\n        for header_name in request_headers:\n            request_headers.update({header_name: jinja_render(template_str=request_headers.get(header_name))})\n\n        timeout = self.datastore.data['settings']['requests'].get('timeout')\n\n        request_body = self.watch.get('body')\n        if request_body:\n            request_body = jinja_render(template_str=self.watch.get('body'))\n\n        request_method = self.watch.get('method')\n        ignore_status_codes = self.watch.get('ignore_status_codes', False)\n\n        # Configurable per-watch or global extra delay before extracting text (for webDriver types)\n        system_webdriver_delay = self.datastore.data['settings']['application'].get('webdriver_delay', None)\n        if self.watch.get('webdriver_delay'):\n            self.fetcher.render_extract_delay = self.watch.get('webdriver_delay')\n        elif system_webdriver_delay is not None:\n            self.fetcher.render_extract_delay = system_webdriver_delay\n\n        if self.watch.get('webdriver_js_execute_code') is not None and self.watch.get('webdriver_js_execute_code').strip():\n            self.fetcher.webdriver_js_execute_code = self.watch.get('webdriver_js_execute_code')\n\n        # Requests for PDF's, images etc should be passwd the is_binary flag\n        is_binary = self.watch.is_pdf\n\n        # And here we go! call the right browser with browser-specific settings\n        empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False)\n        # All fetchers are now async\n        await self.fetcher.run(\n            current_include_filters=self.watch.get('include_filters'),\n            empty_pages_are_a_change=empty_pages_are_a_change,\n            fetch_favicon=self.watch.favicon_is_expired(),\n            ignore_status_codes=ignore_status_codes,\n            is_binary=is_binary,\n            request_body=request_body,\n            request_headers=request_headers,\n            request_method=request_method,\n            screenshot_format=self.screenshot_format,\n            timeout=timeout,\n            url=url,\n            watch_uuid=self.watch_uuid,\n        )\n\n        # @todo .quit here could go on close object, so we can run JS if change-detected\n        await self.fetcher.quit(watch=self.watch)\n\n        # Sanitize lone surrogates - these can appear when servers return malformed/mixed-encoding\n        # content that gets decoded into surrogate characters (e.g. \\udcad). Without this,\n        # encode('utf-8') raises UnicodeEncodeError downstream in checksums, diffs, file writes, etc.\n        # Covers all fetchers (requests, playwright, puppeteer, selenium) in one place.\n        # Also note: By this point we SHOULD know the original encoding so it can safely convert to utf-8 for the rest of the app.\n        # See: https://github.com/dgtlmoon/changedetection.io/issues/3952\n\n        if self.fetcher.content and isinstance(self.fetcher.content, str):\n            self.fetcher.content = self.fetcher.content.encode('utf-8', errors='replace').decode('utf-8')\n\n        # After init, call run_changedetection() which will do the actual change-detection\n\n    def get_extra_watch_config(self, filename):\n        \"\"\"\n        Read processor-specific JSON config file from watch data directory.\n\n        Args:\n            filename: Name of JSON file (e.g., \"visual_ssim_score.json\")\n\n        Returns:\n            dict: Parsed JSON data, or empty dict if file doesn't exist\n        \"\"\"\n        import json\n        import os\n\n        watch = self.datastore.data['watching'].get(self.watch_uuid)\n        data_dir = watch.data_dir\n\n        if not data_dir:\n            return {}\n\n        filepath = os.path.join(data_dir, filename)\n\n        if not os.path.isfile(filepath):\n            return {}\n\n        try:\n            with open(filepath, 'r', encoding='utf-8') as f:\n                return json.load(f)\n        except (json.JSONDecodeError, IOError) as e:\n            logger.warning(f\"Failed to read extra watch config {filename}: {e}\")\n            return {}\n\n    def update_extra_watch_config(self, filename, data, merge=True):\n        \"\"\"\n        Write processor-specific JSON config file to watch data directory.\n\n        Args:\n            filename: Name of JSON file (e.g., \"visual_ssim_score.json\")\n            data: Dictionary to serialize as JSON\n            merge: If True, merge with existing data; if False, overwrite completely\n        \"\"\"\n        import json\n        import os\n\n        watch = self.datastore.data['watching'].get(self.watch_uuid)\n        data_dir = watch.data_dir\n\n        if not data_dir:\n            logger.warning(f\"Cannot save extra watch config {filename}: no data_dir\")\n            return\n\n        # Ensure directory exists\n        watch.ensure_data_dir_exists()\n\n        filepath = os.path.join(data_dir, filename)\n\n        try:\n            # If merge is enabled, read existing data first\n            existing_data = {}\n            if merge and os.path.isfile(filepath):\n                try:\n                    with open(filepath, 'r', encoding='utf-8') as f:\n                        existing_data = json.load(f)\n                except (json.JSONDecodeError, IOError) as e:\n                    logger.warning(f\"Failed to read existing config for merge: {e}\")\n\n            # Merge new data with existing\n            if merge:\n                existing_data.update(data)\n                data_to_save = existing_data\n            else:\n                data_to_save = data\n\n            # Write the data\n            with open(filepath, 'w', encoding='utf-8') as f:\n                json.dump(data_to_save, f, indent=2)\n        except IOError as e:\n            logger.error(f\"Failed to write extra watch config {filename}: {e}\")\n\n    def get_raw_document_checksum(self):\n        checksum = None\n\n        if self.fetcher.content:\n            checksum = hashlib.md5(self.fetcher.content.encode('utf-8')).hexdigest()\n\n        return checksum\n\n    @abstractmethod\n    def run_changedetection(self, watch, force_reprocess=False):\n        update_obj = {'last_notification_error': False, 'last_error': False}\n        some_data = 'xxxxx'\n        update_obj[\"previous_md5\"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()\n        changed_detected = False\n        return changed_detected, update_obj, ''.encode('utf-8')\n"
  },
  {
    "path": "changedetectionio/processors/exceptions.py",
    "content": "class ProcessorException(Exception):\n    def __init__(self, message=None, status_code=None, url=None, screenshot=None, has_filters=False, html_content='', xpath_data=None):\n        self.message = message\n        self.status_code = status_code\n        self.url = url\n        self.screenshot = screenshot\n        self.has_filters = has_filters\n        self.html_content = html_content\n        self.xpath_data = xpath_data\n        return\n"
  },
  {
    "path": "changedetectionio/processors/extract.py",
    "content": "\"\"\"\nBase data extraction module for all processors.\n\nThis module handles extracting data from watch history using regex patterns\nand exporting to CSV format. This is the default extractor that all processors\n(text_json_diff, restock_diff, etc.) can use by default or override.\n\"\"\"\n\nimport os\nfrom flask_babel import gettext\nfrom loguru import logger\n\n\ndef render_form(watch, datastore, request, url_for, render_template, flash, redirect, extract_form=None):\n    \"\"\"\n    Render the data extraction form.\n\n    Args:\n        watch: The watch object\n        datastore: The ChangeDetectionStore instance\n        request: Flask request object\n        url_for: Flask url_for function\n        render_template: Flask render_template function\n        flash: Flask flash function\n        redirect: Flask redirect function\n        extract_form: Optional pre-built extract form (for error cases)\n\n    Returns:\n        Rendered HTML response with the extraction form\n    \"\"\"\n    from changedetectionio import forms\n\n    uuid = watch.get('uuid')\n\n    # Use provided form or create a new one\n    if extract_form is None:\n        extract_form = forms.extractDataForm(\n            formdata=request.form,\n            data={'extract_regex': request.form.get('extract_regex', '')}\n        )\n\n    # Get error information for the template\n    screenshot_url = watch.get_screenshot()\n\n    is_html_webdriver = watch.fetcher_supports_screenshots\n\n    password_enabled_and_share_is_off = False\n    if datastore.data['settings']['application'].get('password') or os.getenv(\"SALTED_PASS\", False):\n        password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access')\n\n    # Use the shared default template from processors/templates/\n    # Processors can override this by creating their own extract.py with custom template logic\n    output = render_template(\n        \"extract.html\",\n        uuid=uuid,\n        extract_form=extract_form,\n        watch_a=watch,\n        last_error=watch['last_error'],\n        last_error_screenshot=watch.get_error_snapshot(),\n        last_error_text=watch.get_error_text(),\n        screenshot=screenshot_url,\n        is_html_webdriver=is_html_webdriver,\n        password_enabled_and_share_is_off=password_enabled_and_share_is_off,\n        extra_title=f\" - {watch.label} - Extract Data\",\n        extra_stylesheets=[url_for('static_content', group='styles', filename='diff.css')],\n        pure_menu_fixed=False\n    )\n\n    return output\n\n\ndef process_extraction(watch, datastore, request, url_for, make_response, send_from_directory, flash, redirect, extract_form=None):\n    \"\"\"\n    Process the data extraction request and return CSV file.\n\n    Args:\n        watch: The watch object\n        datastore: The ChangeDetectionStore instance\n        request: Flask request object\n        url_for: Flask url_for function\n        make_response: Flask make_response function\n        send_from_directory: Flask send_from_directory function\n        flash: Flask flash function\n        redirect: Flask redirect function\n        extract_form: Optional pre-built extract form\n\n    Returns:\n        CSV file download response or redirect to form on error\n    \"\"\"\n    from changedetectionio import forms\n\n    uuid = watch.get('uuid')\n\n    # Use provided form or create a new one\n    if extract_form is None:\n        extract_form = forms.extractDataForm(\n            formdata=request.form,\n            data={'extract_regex': request.form.get('extract_regex', '')}\n        )\n\n    if not extract_form.validate():\n        flash(gettext(\"An error occurred, please see below.\"), \"error\")\n        # render_template needs to be imported from Flask for this to work\n        from flask import render_template as flask_render_template\n        return render_form(\n            watch=watch,\n            datastore=datastore,\n            request=request,\n            url_for=url_for,\n            render_template=flask_render_template,\n            flash=flash,\n            redirect=redirect,\n            extract_form=extract_form\n        )\n\n    extract_regex = request.form.get('extract_regex', '').strip()\n    output = watch.extract_regex_from_all_history(extract_regex)\n\n    if output:\n        watch_dir = os.path.join(datastore.datastore_path, uuid)\n        response = make_response(send_from_directory(directory=watch_dir, path=output, as_attachment=True))\n        response.headers['Content-type'] = 'text/csv'\n        response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'\n        response.headers['Pragma'] = 'no-cache'\n        response.headers['Expires'] = \"0\"\n        return response\n\n    flash(gettext('No matches found while scanning all of the watch history for that RegEx.'), 'error')\n    return redirect(url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid))\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/README.md",
    "content": "# Fast Screenshot Comparison Processor\n\nVisual/screenshot change detection using ultra-fast image comparison algorithms.\n\n## Overview\n\nThis processor uses **OpenCV** by default for screenshot comparison, providing **50-100x faster** performance compared to the previous SSIM implementation while still detecting meaningful visual changes.\n\n## Current Features\n\n- **Ultra-fast OpenCV comparison**: cv2.absdiff with Gaussian blur for noise reduction\n- **MD5 pre-check**: Fast identical image detection before expensive comparison\n- **Configurable sensitivity**: Threshold-based change detection\n- **Three-panel diff view**: Previous | Current | Difference (with red highlights)\n- **Direct image support**: Works with browser screenshots AND direct image URLs\n- **Visual selector support**: Compare specific page regions using CSS/XPath selectors\n- **Download images**: Download any of the three comparison images directly from the diff view\n\n## Performance\n\n- **OpenCV (default)**: 50-100x faster than SSIM\n- **Large screenshots**: Automatic downscaling for diff visualization (configurable via `MAX_DIFF_HEIGHT`/`MAX_DIFF_WIDTH`)\n- **Memory efficient**: Explicit cleanup of large objects for long-running processes\n- **JPEG diff images**: Smaller file sizes, faster rendering\n\n## How It Works\n\n1. **Fetch**: Screenshot captured via browser OR direct image URL fetched\n2. **MD5 Check**: Quick hash comparison - if identical, skip comparison\n3. **Region Selection** (optional): Crop to specific page region if visual selector is configured\n4. **OpenCV Comparison**: Fast pixel-level difference detection with Gaussian blur\n5. **Change Detection**: Percentage of changed pixels above threshold = change detected\n6. **Visualization**: Generate diff image with red-highlighted changed regions\n\n## Architecture\n\n### Default Method: OpenCV\n\nThe processor uses OpenCV's `cv2.absdiff()` for ultra-fast pixel-level comparison:\n\n```python\n# Convert to grayscale\ngray_from = cv2.cvtColor(image_from, cv2.COLOR_RGB2GRAY)\ngray_to = cv2.cvtColor(image_to, cv2.COLOR_RGB2GRAY)\n\n# Apply Gaussian blur (reduces noise, controlled by OPENCV_BLUR_SIGMA env var)\ngray_from = cv2.GaussianBlur(gray_from, (0, 0), sigma=0.8)\ngray_to = cv2.GaussianBlur(gray_to, (0, 0), sigma=0.8)\n\n# Calculate absolute difference\ndiff = cv2.absdiff(gray_from, gray_to)\n\n# Apply threshold (default: 30)\n_, thresh = cv2.threshold(diff, threshold, 255, cv2.THRESH_BINARY)\n\n# Count changed pixels\nchange_percentage = (changed_pixels / total_pixels) * 100\n```\n\n### Optional: Pixelmatch\n\nFor users who need better anti-aliasing detection (especially for text-heavy screenshots), **pixelmatch** can be optionally installed:\n\n```bash\npip install pybind11-pixelmatch>=0.1.3\n```\n\n**Note**: Pixelmatch uses a C++17 implementation via pybind11 and may have build issues on some platforms (particularly Alpine/musl systems with symbolic link security restrictions). The application will automatically fall back to OpenCV if pixelmatch is not available.\n\nTo use pixelmatch instead of OpenCV, set the environment variable:\n```bash\nCOMPARISON_METHOD=pixelmatch\n```\n\n#### When to use pixelmatch:\n- Screenshots with lots of text and anti-aliasing\n- Need to ignore minor font rendering differences between browser versions\n- 10-20x faster than SSIM (but slower than OpenCV)\n\n#### When to stick with OpenCV (default):\n- General webpage monitoring\n- Maximum performance (50-100x faster than SSIM)\n- Simple pixel-level change detection\n- Avoid build dependencies (Alpine/musl systems)\n\n## Configuration\n\n### Environment Variables\n\n```bash\n# Comparison method (opencv or pixelmatch)\nCOMPARISON_METHOD=opencv  # Default\n\n# OpenCV threshold (0-255, lower = more sensitive)\nCOMPARISON_THRESHOLD_OPENCV=30  # Default\n\n# Pixelmatch threshold (0-100, mapped to 0-1 scale)\nCOMPARISON_THRESHOLD_PIXELMATCH=10  # Default\n\n# Gaussian blur sigma for OpenCV (0 = no blur, higher = more blur)\nOPENCV_BLUR_SIGMA=0.8  # Default\n\n# Minimum change percentage to trigger detection\nOPENCV_MIN_CHANGE_PERCENT=0.1  # Default (0.1%)\nPIXELMATCH_MIN_CHANGE_PERCENT=0.1  # Default\n\n# Diff visualization image size limits (pixels)\nMAX_DIFF_HEIGHT=8000  # Default\nMAX_DIFF_WIDTH=900  # Default\n```\n\n### Per-Watch Configuration\n\n- **Comparison Threshold**: Can be configured per-watch in the edit form\n  - Very low sensitivity (10) - Only major changes\n  - Low sensitivity (20) - Significant changes\n  - Medium sensitivity (30) - Moderate changes (default)\n  - High sensitivity (50) - Small changes\n  - Very high sensitivity (75) - Any visible change\n\n### Visual Selector (Region Comparison)\n\nUse the \"Include filters\" field with CSS selectors or XPath to compare only specific page regions:\n\n```\n.content-area\n//div[@id='main']\n```\n\nThe processor will automatically crop both screenshots to the bounding box of the first matched element.\n\n## Dependencies\n\n### Required\n- `opencv-python-headless>=4.8.0.76` - Fast image comparison\n- `Pillow (PIL)` - Image loading and manipulation\n- `numpy` - Array operations\n\n### Optional\n- `pybind11-pixelmatch>=0.1.3` - Alternative comparison method with anti-aliasing detection\n\n## Change Detection Interpretation\n\n- **0%** = Identical images (or below minimum change threshold)\n- **0.1-1%** = Minor differences (anti-aliasing, slight rendering differences)\n- **1-5%** = Noticeable changes (text updates, small content changes)\n- **5-20%** = Significant changes (layout shifts, content additions)\n- **>20%** = Major differences (page redesign, large content changes)\n\n## Technical Notes\n\n### Memory Management\n```python\n# Explicit cleanup for long-running processes\nimg.close()  # Close PIL Images\nbuffer.close()  # Close BytesIO buffers\ndel large_array  # Mark numpy arrays for GC\n```\n\n### Diff Image Generation\n- Format: JPEG (quality=85, optimized)\n- Highlight: Red overlay (50% blend with original)\n- Auto-downscaling: Large screenshots downscaled for faster rendering\n- Base64 embedded: For direct template rendering\n\n### OpenCV Blur Parameters\nThe Gaussian blur reduces sensitivity to:\n- Font rendering differences\n- Anti-aliasing variations\n- JPEG compression artifacts\n- Minor pixel shifts (1-2 pixels)\n\nIncrease `OPENCV_BLUR_SIGMA` to make comparison more tolerant of these differences.\n\n## Comparison: OpenCV vs Pixelmatch vs SSIM\n\n| Feature | OpenCV | Pixelmatch | SSIM (old) |\n|---------|--------|------------|------------|\n| **Speed** | 50-100x faster | 10-20x faster | Baseline |\n| **Anti-aliasing** | Via blur | Built-in detection | Built-in |\n| **Text sensitivity** | High | Medium (AA-aware) | Medium |\n| **Dependencies** | opencv-python-headless | pybind11-pixelmatch + C++ compiler | scikit-image |\n| **Alpine/musl support** | ✅ Yes | ⚠️ Build issues | ✅ Yes |\n| **Memory usage** | Low | Low | High |\n| **Best for** | General use, max speed | Text-heavy screenshots | Deprecated |\n\n## Migration from SSIM\n\nIf you're upgrading from the old SSIM-based processor:\n\n1. **Thresholds are different**: SSIM used 0-1 scale (higher = more similar), OpenCV uses 0-255 pixel difference (lower = more similar)\n2. **Default threshold**: Start with 30 for OpenCV, adjust based on your needs\n3. **Performance**: Expect dramatically faster comparisons, especially for large screenshots\n4. **Accuracy**: OpenCV is more sensitive to pixel-level changes; increase `OPENCV_BLUR_SIGMA` if you're getting false positives\n\n## Future Enhancements\n\nPotential features for future consideration:\n\n- **Change region detection**: Highlight specific areas that changed with bounding boxes\n- **Perceptual hashing**: Pre-screening filter for even faster checks\n- **Ignore regions**: Exclude specific page areas (ads, timestamps) from comparison\n- **Text extraction**: OCR-based text comparison for semantic changes\n- **Adaptive thresholds**: Different sensitivity for different page regions\n\n## Resources\n\n- [OpenCV Documentation](https://docs.opencv.org/)\n- [pybind11-pixelmatch GitHub](https://github.com/whtsky/pybind11-pixelmatch)\n- [Pixelmatch (original JS library)](https://github.com/mapbox/pixelmatch)\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/__init__.py",
    "content": "\"\"\"\nVisual/screenshot change detection using fast image comparison algorithms.\n\nThis processor compares screenshots using OpenCV (cv2.absdiff),\nwhich is 10-100x faster than SSIM while still detecting meaningful visual changes.\n\"\"\"\n\nimport os\nfrom pathlib import Path\n\nprocessor_description = \"Visual/Screenshot change detection (Fast)\"\nprocessor_name = \"image_ssim_diff\"\nprocessor_weight = 2  # Lower weight = appears at top, heavier weight = appears lower (bottom)\n\n# Processor capabilities\nsupports_visual_selector = True\nsupports_browser_steps = True\nsupports_text_filters_and_triggers = False\nsupports_text_filters_and_triggers_elements = False\nsupports_request_type = True\n\nPROCESSOR_CONFIG_NAME = f\"{Path(__file__).parent.name}.json\"\n\n# Subprocess timeout settings\n# Maximum time to wait for subprocess operations (seconds)\nPOLL_TIMEOUT_ABSOLUTE = int(os.getenv('OPENCV_SUBPROCESS_TIMEOUT', '20'))\n\n# Template tracking filename\nCROPPED_IMAGE_TEMPLATE_FILENAME = 'cropped_image_template.png'\n\nSCREENSHOT_COMPARISON_THRESHOLD_OPTIONS = [\n    ('200', 'Low sensitivity (only major changes)'),\n    ('80', 'Medium sensitivity (moderate changes - recommended)'),\n    ('20', 'High sensitivity (small changes)'),\n    ('0', 'Very high sensitivity (any change)')\n]\n\nSCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT=0.999\nOPENCV_BLUR_SIGMA=float(os.getenv(\"OPENCV_BLUR_SIGMA\", \"3.0\"))\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/difference.py",
    "content": "\"\"\"\nScreenshot diff visualization for fast image comparison processor.\n\nAll image operations now use ImageDiffHandler abstraction for clean separation\nof concerns and easy backend swapping (LibVIPS, OpenCV, PIL, etc.).\n\"\"\"\n\nimport os\nimport json\nimport time\nfrom flask_babel import gettext\nfrom loguru import logger\n\nfrom changedetectionio.processors.image_ssim_diff import SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT, PROCESSOR_CONFIG_NAME, \\\n    OPENCV_BLUR_SIGMA\n\n# All image operations now use OpenCV via isolated_opencv subprocess handler\n# No direct handler imports needed - subprocess isolation handles everything\n\n# Maximum dimensions for diff visualization (can be overridden via environment variable)\n# Large screenshots don't need full resolution for visual inspection\n# Reduced defaults to minimize memory usage - 2000px height is plenty for diff viewing\nMAX_DIFF_HEIGHT = int(os.getenv('MAX_DIFF_HEIGHT', '8000'))\nMAX_DIFF_WIDTH = int(os.getenv('MAX_DIFF_WIDTH', '900'))\n\n\ndef get_asset(asset_name, watch, datastore, request):\n    \"\"\"\n    Get processor-specific binary assets for streaming.\n\n    Uses ImageDiffHandler for all image operations - no more multiprocessing needed\n    as LibVIPS handles threading/memory internally.\n\n    Supported assets:\n    - 'before': The previous/from screenshot\n    - 'after': The current/to screenshot\n    - 'rendered_diff': The generated diff visualization with red highlights\n\n    Args:\n        asset_name: Name of the asset to retrieve ('before', 'after', 'rendered_diff')\n        watch: Watch object\n        datastore: Datastore object\n        request: Flask request (for from_version/to_version query params)\n\n    Returns:\n        tuple: (binary_data, content_type, cache_control_header) or None if not found\n    \"\"\"\n    # Get version parameters from query string\n    versions = list(watch.history.keys())\n\n    if len(versions) < 2:\n        return None\n\n    from_version = request.args.get('from_version', versions[-2] if len(versions) >= 2 else versions[0])\n    to_version = request.args.get('to_version', versions[-1])\n\n    # Validate versions exist\n    if from_version not in versions:\n        from_version = versions[-2] if len(versions) >= 2 else versions[0]\n    if to_version not in versions:\n        to_version = versions[-1]\n\n    try:\n        if asset_name == 'before':\n            # Return the 'from' screenshot with bounding box if configured\n            img_bytes = watch.get_history_snapshot(timestamp=from_version)\n            img_bytes = _draw_bounding_box_if_configured(img_bytes, watch, datastore)\n            mime_type = _detect_mime_type(img_bytes)\n            return (img_bytes, mime_type, 'public, max-age=3600')\n\n        elif asset_name == 'after':\n            # Return the 'to' screenshot with bounding box if configured\n            img_bytes = watch.get_history_snapshot(timestamp=to_version)\n            img_bytes = _draw_bounding_box_if_configured(img_bytes, watch, datastore)\n            mime_type = _detect_mime_type(img_bytes)\n            return (img_bytes, mime_type, 'public, max-age=3600')\n\n        elif asset_name == 'rendered_diff':\n            # Generate diff in isolated subprocess to prevent memory leaks\n            # Subprocess provides complete memory isolation\n            from .image_handler import isolated_opencv as process_screenshot_handler\n\n            img_bytes_from = watch.get_history_snapshot(timestamp=from_version)\n            img_bytes_to = watch.get_history_snapshot(timestamp=to_version)\n\n            # Get pixel difference threshold sensitivity (per-watch > global)\n            # This controls how different a pixel must be (0-255 scale) to count as \"changed\"\n            from changedetectionio import processors\n            processor_instance = processors.difference_detection_processor(datastore, watch.get('uuid'))\n            processor_config = processor_instance.get_extra_watch_config(PROCESSOR_CONFIG_NAME)\n\n            pixel_difference_threshold_sensitivity = processor_config.get('pixel_difference_threshold_sensitivity')\n            if not pixel_difference_threshold_sensitivity:\n                pixel_difference_threshold_sensitivity = datastore.data['settings']['application'].get(\n                    'pixel_difference_threshold_sensitivity', SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT)\n            try:\n                pixel_difference_threshold_sensitivity = int(pixel_difference_threshold_sensitivity)\n            except (ValueError, TypeError):\n                logger.warning(\n                    f\"Invalid pixel_difference_threshold_sensitivity value '{pixel_difference_threshold_sensitivity}', using default\")\n                pixel_difference_threshold_sensitivity = SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT\n\n            logger.debug(f\"Pixel difference threshold sensitivity is {pixel_difference_threshold_sensitivity}\")\n\n\n            # Generate diff in isolated subprocess (async-safe)\n            import asyncio\n            import threading\n\n            # Async-safe wrapper: runs coroutine in new thread with its own event loop\n            def run_async_in_thread():\n                return asyncio.run(\n                    process_screenshot_handler.generate_diff_isolated(\n                        img_bytes_from,\n                        img_bytes_to,\n                        pixel_difference_threshold=int(pixel_difference_threshold_sensitivity),\n                        blur_sigma=OPENCV_BLUR_SIGMA,\n                        max_width=MAX_DIFF_WIDTH,\n                        max_height=MAX_DIFF_HEIGHT\n                    )\n                )\n\n            # Run in thread to avoid blocking event loop if called from async context\n            result_container = [None]\n            exception_container = [None]\n\n            def thread_target():\n                try:\n                    result_container[0] = run_async_in_thread()\n                except Exception as e:\n                    exception_container[0] = e\n\n            thread = threading.Thread(target=thread_target, daemon=True, name=\"ImageDiff-Asset\")\n            thread.start()\n            thread.join(timeout=60)\n\n            if exception_container[0]:\n                raise exception_container[0]\n\n            diff_image_bytes = result_container[0]\n\n            if diff_image_bytes:\n                # Note: Bounding box drawing on diff not yet implemented\n                return (diff_image_bytes, 'image/jpeg', 'public, max-age=300')\n            else:\n                logger.error(\"Failed to generate diff in subprocess\")\n                return None\n\n        else:\n            # Unknown asset\n            return None\n\n    except Exception as e:\n        logger.error(f\"Failed to get asset '{asset_name}': {e}\")\n        import traceback\n        logger.error(traceback.format_exc())\n        return None\n\n\ndef _detect_mime_type(img_bytes):\n    \"\"\"\n    Detect MIME type using puremagic (same as Watch.py).\n\n    Args:\n        img_bytes: Image bytes\n\n    Returns:\n        str: MIME type (e.g., 'image/png', 'image/jpeg')\n    \"\"\"\n    try:\n        import puremagic\n        detections = puremagic.magic_string(img_bytes[:2048])\n        if detections:\n            mime_type = detections[0].mime_type\n            logger.trace(f\"Detected MIME type: {mime_type}\")\n            return mime_type\n        else:\n            logger.trace(\"No MIME type detected, using 'image/png' fallback\")\n            return 'image/png'\n    except Exception as e:\n        logger.warning(f\"puremagic detection failed: {e}, using 'image/png' fallback\")\n        return 'image/png'\n\n\ndef _draw_bounding_box_if_configured(img_bytes, watch, datastore):\n    \"\"\"\n    Draw blue bounding box on image if configured in processor settings.\n    Uses isolated subprocess to prevent memory leaks from large images.\n\n    Supports two modes:\n    - \"Select by element\": Use include_filter to find xpath element bbox\n    - \"Draw area\": Use manually drawn bounding_box from config\n\n    Args:\n        img_bytes: Image bytes (PNG)\n        watch: Watch object\n        datastore: Datastore object\n\n    Returns:\n        Image bytes (possibly with bounding box drawn)\n    \"\"\"\n    try:\n        # Get processor configuration\n        from changedetectionio import processors\n        processor_instance = processors.difference_detection_processor(datastore, watch.get('uuid'))\n        processor_name = watch.get('processor', 'default')\n        config_filename = f'{processor_name}.json'\n        processor_config = processor_instance.get_extra_watch_config(config_filename)\n\n        if not processor_config:\n            return img_bytes\n\n        selection_mode = processor_config.get('selection_mode', 'draw')\n        x, y, width, height = None, None, None, None\n\n        # Mode 1: Select by element (use include_filter + xpath_data)\n        if selection_mode == 'element':\n            include_filters = watch.get('include_filters', [])\n\n            if include_filters and len(include_filters) > 0:\n                first_filter = include_filters[0].strip()\n\n                # Get xpath_data from watch history\n                history_keys = list(watch.history.keys())\n                if history_keys:\n                    latest_snapshot = watch.get_history_snapshot(timestamp=history_keys[-1])\n                    xpath_data_path = watch.get_xpath_data_filepath(timestamp=history_keys[-1])\n\n                    try:\n                        import gzip\n                        with gzip.open(xpath_data_path, 'rt') as f:\n                            xpath_data = json.load(f)\n\n                        # Find matching element\n                        for element in xpath_data.get('size_pos', []):\n                            if element.get('xpath') == first_filter and element.get('highlight_as_custom_filter'):\n                                x = element.get('left', 0)\n                                y = element.get('top', 0)\n                                width = element.get('width', 0)\n                                height = element.get('height', 0)\n                                logger.debug(f\"Found element bbox for filter '{first_filter}': x={x}, y={y}, w={width}, h={height}\")\n                                break\n                    except Exception as e:\n                        logger.warning(f\"Failed to load xpath_data for element selection: {e}\")\n\n        # Mode 2: Draw area (use manually configured bbox)\n        else:\n            bounding_box = processor_config.get('bounding_box')\n            if bounding_box:\n                # Parse bounding box: \"x,y,width,height\"\n                parts = [int(p.strip()) for p in bounding_box.split(',')]\n                if len(parts) == 4:\n                    x, y, width, height = parts\n                else:\n                    logger.warning(f\"Invalid bounding box format: {bounding_box}\")\n\n        # If no bbox found, return original image\n        if x is None or y is None or width is None or height is None:\n            return img_bytes\n\n        # Use isolated subprocess to prevent memory leaks from large images\n        from .image_handler import isolated_opencv\n        import asyncio\n        import threading\n\n        # Async-safe wrapper: runs coroutine in new thread with its own event loop\n        # This prevents blocking when called from async context (update worker)\n        def run_async_in_thread():\n            return asyncio.run(\n                isolated_opencv.draw_bounding_box_isolated(\n                    img_bytes, x, y, width, height,\n                    color=(255, 0, 0),  # Blue in BGR format\n                    thickness=3\n                )\n            )\n\n        # Always run in thread to avoid blocking event loop if called from async context\n        result_container = [None]\n        exception_container = [None]\n\n        def thread_target():\n            try:\n                result_container[0] = run_async_in_thread()\n            except Exception as e:\n                exception_container[0] = e\n\n        thread = threading.Thread(target=thread_target, daemon=True, name=\"ImageDiff-BoundingBox\")\n        thread.start()\n        thread.join(timeout=15)\n\n        if exception_container[0]:\n            raise exception_container[0]\n\n        result = result_container[0]\n\n        # Return result or original if subprocess failed\n        return result if result else img_bytes\n\n    except Exception as e:\n        logger.warning(f\"Failed to draw bounding box: {e}\")\n        import traceback\n        logger.debug(traceback.format_exc())\n        return img_bytes\n\n\ndef render(watch, datastore, request, url_for, render_template, flash, redirect):\n    \"\"\"\n    Render the screenshot comparison diff page.\n\n    Uses ImageDiffHandler for all image operations.\n\n    Args:\n        watch: Watch object\n        datastore: Datastore object\n        request: Flask request\n        url_for: Flask url_for function\n        render_template: Flask render_template function\n        flash: Flask flash function\n        redirect: Flask redirect function\n\n    Returns:\n        Rendered template or redirect\n    \"\"\"\n    # Get version parameters (from_version, to_version)\n    versions = list(watch.history.keys())\n\n    if len(versions) < 2:\n        flash(gettext(\"Not enough history to compare. Need at least 2 snapshots.\"), \"error\")\n        return redirect(url_for('watchlist.index'))\n\n    # Default: compare latest two versions\n    from_version = request.args.get('from_version', versions[-2] if len(versions) >= 2 else versions[0])\n    to_version = request.args.get('to_version', versions[-1])\n\n    # Validate versions exist\n    if from_version not in versions:\n        from_version = versions[-2] if len(versions) >= 2 else versions[0]\n    if to_version not in versions:\n        to_version = versions[-1]\n\n    # Get pixel difference threshold sensitivity (per-watch > global > env default)\n    pixel_difference_threshold_sensitivity = watch.get('pixel_difference_threshold_sensitivity')\n    if not pixel_difference_threshold_sensitivity or pixel_difference_threshold_sensitivity == '':\n        pixel_difference_threshold_sensitivity = datastore.data['settings']['application'].get('pixel_difference_threshold_sensitivity', SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT)\n\n    # Convert to appropriate type\n    try:\n        pixel_difference_threshold_sensitivity = float(pixel_difference_threshold_sensitivity)\n    except (ValueError, TypeError):\n        logger.warning(f\"Invalid pixel_difference_threshold_sensitivity value '{pixel_difference_threshold_sensitivity}', using default\")\n        pixel_difference_threshold_sensitivity = 30.0\n\n    # Get blur sigma\n    blur_sigma = OPENCV_BLUR_SIGMA\n\n    # Load screenshots from history\n    try:\n        img_bytes_from = watch.get_history_snapshot(timestamp=from_version)\n        img_bytes_to = watch.get_history_snapshot(timestamp=to_version)\n\n    except Exception as e:\n        logger.error(f\"Failed to load screenshots: {e}\")\n        flash(gettext(\"Failed to load screenshots: {}\").format(e), \"error\")\n        return redirect(url_for('watchlist.index'))\n\n    # Calculate change percentage using isolated subprocess to prevent memory leaks (async-safe)\n    now = time.time()\n    try:\n        from .image_handler import isolated_opencv as process_screenshot_handler\n        import asyncio\n        import threading\n\n        # Async-safe wrapper: runs coroutine in new thread with its own event loop\n        def run_async_in_thread():\n            return asyncio.run(\n                process_screenshot_handler.calculate_change_percentage_isolated(\n                    img_bytes_from,\n                    img_bytes_to,\n                    pixel_difference_threshold=int(pixel_difference_threshold_sensitivity),\n                    blur_sigma=blur_sigma,\n                    max_width=MAX_DIFF_WIDTH,\n                    max_height=MAX_DIFF_HEIGHT\n                )\n            )\n\n        # Run in thread to avoid blocking event loop if called from async context\n        result_container = [None]\n        exception_container = [None]\n\n        def thread_target():\n            try:\n                result_container[0] = run_async_in_thread()\n            except Exception as e:\n                exception_container[0] = e\n\n        thread = threading.Thread(target=thread_target, daemon=True, name=\"ImageDiff-ChangePercentage\")\n        thread.start()\n        thread.join(timeout=60)\n\n        if exception_container[0]:\n            raise exception_container[0]\n\n        change_percentage = result_container[0]\n\n        method_display = f\"{process_screenshot_handler.IMPLEMENTATION_NAME} (pixel_diff_threshold: {pixel_difference_threshold_sensitivity:.0f})\"\n        logger.debug(f\"Done change percentage calculation in {time.time() - now:.2f}s\")\n\n    except Exception as e:\n        logger.error(f\"Failed to calculate change percentage: {e}\")\n        import traceback\n        logger.error(traceback.format_exc())\n        flash(gettext(\"Failed to calculate diff: {}\").format(e), \"error\")\n        return redirect(url_for('watchlist.index'))\n\n    # Load historical data if available (for charts/visualization)\n    comparison_data = {}\n    comparison_config_path = os.path.join(watch.data_dir, \"visual_comparison_data.json\")\n    if os.path.isfile(comparison_config_path):\n        try:\n            with open(comparison_config_path, 'r') as f:\n                comparison_data = json.load(f)\n        except Exception as e:\n            logger.warning(f\"Failed to load comparison history data: {e}\")\n\n    # Render custom template\n    # Template path is namespaced to avoid conflicts with other processors\n    # Images are now served via separate /processor-asset/ endpoints instead of base64\n    return render_template(\n        'image_ssim_diff/diff.html',\n        change_percentage=change_percentage,\n        comparison_data=comparison_data,  # Full history for charts/visualization\n        comparison_method=method_display,\n        current_diff_url=watch['url'],\n        from_version=from_version,\n        percentage_different=change_percentage,\n        threshold=pixel_difference_threshold_sensitivity,\n        to_version=to_version,\n        uuid=watch.get('uuid'),\n        versions=versions,\n        watch=watch,\n    )\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/edit_hook.py",
    "content": "\"\"\"\nOptional hook called when processor settings are saved in edit page.\n\nThis hook analyzes the selected region to determine if template matching\nshould be enabled for tracking content movement.\n\nTemplate matching is controlled via ENABLE_TEMPLATE_TRACKING env var (default: False).\n\"\"\"\n\nimport io\nimport os\nfrom loguru import logger\nfrom changedetectionio import strtobool\nfrom . import CROPPED_IMAGE_TEMPLATE_FILENAME\n\n# Template matching controlled via environment variable (default: disabled)\n# Set ENABLE_TEMPLATE_TRACKING=True to enable\nTEMPLATE_MATCHING_ENABLED = strtobool(os.getenv('ENABLE_TEMPLATE_TRACKING', 'False'))\nIMPORT_ERROR = \"Template matching disabled (set ENABLE_TEMPLATE_TRACKING=True to enable)\"\n\n\ndef on_config_save(watch, processor_config, datastore):\n    \"\"\"\n    Called after processor config is saved in edit page.\n\n    Analyzes the bounding box region to determine if it has enough\n    visual features (texture/edges) to enable template matching for\n    tracking content movement when page layout shifts.\n\n    Args:\n        watch: Watch object\n        processor_config: Dict of processor-specific config\n        datastore: Datastore object\n\n    Returns:\n        dict: Updated processor_config with auto_track_region setting\n    \"\"\"\n    # Check if template matching is globally enabled via ENV var\n    if not TEMPLATE_MATCHING_ENABLED:\n        logger.debug(\"Template tracking disabled via ENABLE_TEMPLATE_TRACKING env var\")\n        processor_config['auto_track_region'] = False\n        return processor_config\n\n    bounding_box = processor_config.get('bounding_box')\n\n    if not bounding_box:\n        # No bounding box, disable tracking\n        processor_config['auto_track_region'] = False\n        logger.debug(\"No bounding box set, disabled auto-tracking\")\n        return processor_config\n\n    try:\n        # Get the latest screenshot from watch history\n        history_keys = list(watch.history.keys())\n        if len(history_keys) == 0:\n            logger.warning(\"No screenshot history available yet, cannot analyze for tracking\")\n            processor_config['auto_track_region'] = False\n            return processor_config\n\n        # Get latest screenshot\n        latest_timestamp = history_keys[-1]\n        screenshot_bytes = watch.get_history_snapshot(timestamp=latest_timestamp)\n\n        if not screenshot_bytes:\n            logger.warning(\"Could not load screenshot for analysis\")\n            processor_config['auto_track_region'] = False\n            return processor_config\n\n        # Parse bounding box\n        parts = [int(p.strip()) for p in bounding_box.split(',')]\n        if len(parts) != 4:\n            logger.warning(\"Invalid bounding box format\")\n            processor_config['auto_track_region'] = False\n            return processor_config\n\n        x, y, width, height = parts\n\n        # Analyze the region for features/texture\n        has_enough_features = analyze_region_features(screenshot_bytes, x, y, width, height)\n\n        if has_enough_features:\n            logger.info(f\"Region has sufficient features for tracking - enabling auto_track_region\")\n            processor_config['auto_track_region'] = True\n\n            # Save the template as cropped.jpg in watch data directory\n            save_template_to_file(watch, screenshot_bytes, x, y, width, height)\n\n        else:\n            logger.info(f\"Region lacks distinctive features - disabling auto_track_region\")\n            processor_config['auto_track_region'] = False\n\n            # Remove old template file if exists\n            template_path = os.path.join(watch.data_dir, CROPPED_IMAGE_TEMPLATE_FILENAME)\n            if os.path.exists(template_path):\n                os.remove(template_path)\n                logger.debug(f\"Removed old template file: {template_path}\")\n\n        return processor_config\n\n    except Exception as e:\n        logger.error(f\"Error analyzing region for tracking: {e}\")\n        processor_config['auto_track_region'] = False\n        return processor_config\n\n\ndef analyze_region_features(screenshot_bytes, x, y, width, height):\n    \"\"\"\n    Analyze if a region has enough visual features for template matching.\n\n    Uses OpenCV to detect corners/edges. If the region has distinctive\n    features, template matching can reliably track it when it moves.\n\n    Args:\n        screenshot_bytes: Full screenshot as bytes\n        x, y, width, height: Bounding box coordinates\n\n    Returns:\n        bool: True if region has enough features, False otherwise\n    \"\"\"\n    # Template matching disabled - would need OpenCV implementation for region analysis\n    if not TEMPLATE_MATCHING_ENABLED:\n        logger.warning(f\"Cannot analyze region features: {IMPORT_ERROR}\")\n        return False\n\n    # Note: Original implementation used LibVIPS handler to crop region, then OpenCV\n    # for feature detection (goodFeaturesToTrack, Canny edge detection, variance).\n    # If re-implementing, use OpenCV directly for both cropping and analysis.\n    # Feature detection would use: cv2.goodFeaturesToTrack, cv2.Canny, np.var\n    return False\n\n\ndef save_template_to_file(watch, screenshot_bytes, x, y, width, height):\n    \"\"\"\n    Extract the template region and save as cropped_image_template.png in watch data directory.\n\n    This is a convenience wrapper around handler.save_template() that handles\n    watch directory setup and path construction.\n\n    Args:\n        watch: Watch object\n        screenshot_bytes: Full screenshot as bytes\n        x, y, width, height: Bounding box coordinates\n    \"\"\"\n    # Template matching disabled - would need OpenCV implementation for template saving\n    if not TEMPLATE_MATCHING_ENABLED:\n        logger.warning(f\"Cannot save template: {IMPORT_ERROR}\")\n        return\n\n    # Note: Original implementation used LibVIPS handler to crop and save region.\n    # If re-implementing, use OpenCV (cv2.imdecode, crop with array slicing, cv2.imwrite).\n    return\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/forms.py",
    "content": "\"\"\"\nConfiguration forms for fast screenshot comparison processor.\n\"\"\"\n\nfrom wtforms import SelectField, StringField, validators, ValidationError, IntegerField\nfrom flask_babel import lazy_gettext as _l\nfrom changedetectionio.forms import processor_text_json_diff_form\nimport re\n\nfrom changedetectionio.processors.image_ssim_diff import SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS\n\n\ndef validate_bounding_box(form, field):\n    \"\"\"Validate bounding box format: x,y,width,height with integers.\"\"\"\n    if not field.data:\n        return  # Optional field\n\n    if len(field.data) > 100:\n        raise ValidationError(_l('Bounding box value is too long'))\n\n    # Should be comma-separated integers\n    if not re.match(r'^\\d+,\\d+,\\d+,\\d+$', field.data):\n        raise ValidationError(_l('Bounding box must be in format: x,y,width,height (integers only)'))\n\n    # Validate values are reasonable (not negative, not ridiculously large)\n    parts = [int(p) for p in field.data.split(',')]\n    for part in parts:\n        if part < 0:\n            raise ValidationError(_l('Bounding box values must be non-negative'))\n        if part > 10000:  # Reasonable max screen dimension\n            raise ValidationError(_l('Bounding box values are too large'))\n\n\ndef validate_selection_mode(form, field):\n    \"\"\"Validate selection mode value.\"\"\"\n    if not field.data:\n        return  # Optional field\n\n    if field.data not in ['element', 'draw']:\n        raise ValidationError(_l('Selection mode must be either \"element\" or \"draw\"'))\n\n\nclass processor_settings_form(processor_text_json_diff_form):\n    \"\"\"Form for fast image comparison processor settings.\"\"\"\n\n    processor_config_min_change_percentage = IntegerField(\n        _l('Minimum Change Percentage'),\n        validators=[\n            validators.Optional(),\n            validators.NumberRange(min=1, max=100, message=_l('Must be between 0 and 100'))\n        ],\n        render_kw={\"placeholder\": \"Use global default (0.1)\"}\n    )\n\n    processor_config_pixel_difference_threshold_sensitivity = SelectField(\n        _l('Pixel Difference Sensitivity'),\n        choices=[\n                    ('', _l('Use global default'))\n                ] + SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS,\n        validators=[validators.Optional()],\n        default=''\n    )\n\n    # Processor-specific config fields (stored in separate JSON file)\n    processor_config_bounding_box = StringField(\n        _l('Bounding Box'),\n        validators=[\n            validators.Optional(),\n            validators.Length(max=100, message=_l('Bounding box value is too long')),\n            validate_bounding_box\n        ],\n        render_kw={\"style\": \"display: none;\", \"id\": \"bounding_box\"}\n    )\n\n    processor_config_selection_mode = StringField(\n        _l('Selection Mode'),\n        validators=[\n            validators.Optional(),\n            validators.Length(max=20, message=_l('Selection mode value is too long')),\n            validate_selection_mode\n        ],\n        render_kw={\"style\": \"display: none;\", \"id\": \"selection_mode\"}\n    )\n\n    def extra_tab_content(self):\n        \"\"\"Tab label for processor-specific settings.\"\"\"\n        return _l('Screenshot Comparison')\n\n    def extra_form_content(self):\n        \"\"\"Render processor-specific form fields.\n        @NOTE: prepend processor_config_* to the field name so it will save into its own datadir/uuid/image_ssim_diff.json and be read at process time\n        \"\"\"\n        return '''\n        {% from '_helpers.html' import render_field %}\n        <fieldset>\n            <legend>Screenshot Comparison Settings</legend>\n\n            <div class=\"pure-control-group\">\n                {{ render_field(form.processor_config_min_change_percentage) }}\n                <span class=\"pure-form-message-inline\">\n                    <strong>What percentage of pixels must change to trigger a detection?</strong><br>\n                    For example, <strong>0.1%</strong> means if 0.1% or more of the pixels change, it counts as a change.<br>\n                    Lower values = more sensitive (detect smaller changes).<br>\n                    Higher values = less sensitive (only detect larger changes).<br>\n                    Leave blank to use global default (0.1%).\n                </span>\n            </div>\n\n            <div class=\"pure-control-group\">\n                {{ render_field(form.processor_config_pixel_difference_threshold_sensitivity) }}\n                <span class=\"pure-form-message-inline\">\n                    <strong>How different must an individual pixel be to count as \"changed\"?</strong><br>\n                    <strong>Low sensitivity (75)</strong> = Only count pixels that changed significantly (0-255 scale).<br>\n                    <strong>High sensitivity (20)</strong> = Count pixels with small changes as different.<br>\n                    <strong>Very high (0)</strong> = Any pixel change counts.<br>\n                    Select \"Use global default\" to inherit the system-wide setting.\n                </span>\n            </div>\n        </fieldset>\n        '''\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/image_handler/__init__.py",
    "content": "\"\"\"\nAbstract base class for image processing operations.\n\nAll image operations for the image_ssim_diff processor must be implemented\nthrough this interface to allow different backends (libvips, OpenCV, PIL, etc.).\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom typing import Tuple, Optional, Any\n\n\nclass ImageDiffHandler(ABC):\n    \"\"\"\n    Abstract base class for image processing operations.\n\n    Implementations must handle all image operations needed for screenshot\n    comparison including loading, cropping, resizing, diffing, and overlays.\n    \"\"\"\n\n    @abstractmethod\n    def load_from_bytes(self, img_bytes: bytes) -> Any:\n        \"\"\"\n        Load image from bytes.\n\n        Args:\n            img_bytes: Image data as bytes (PNG, JPEG, etc.)\n\n        Returns:\n            Handler-specific image object\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def save_to_bytes(self, img: Any, format: str = 'png', quality: int = 85) -> bytes:\n        \"\"\"\n        Save image to bytes.\n\n        Args:\n            img: Handler-specific image object\n            format: Output format ('png' or 'jpeg')\n            quality: Quality for JPEG (1-100)\n\n        Returns:\n            Image data as bytes\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def crop(self, img: Any, left: int, top: int, right: int, bottom: int) -> Any:\n        \"\"\"\n        Crop image to specified region.\n\n        Args:\n            img: Handler-specific image object\n            left: Left coordinate\n            top: Top coordinate\n            right: Right coordinate\n            bottom: Bottom coordinate\n\n        Returns:\n            Cropped image object\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def resize(self, img: Any, max_width: int, max_height: int) -> Any:\n        \"\"\"\n        Resize image maintaining aspect ratio.\n\n        Args:\n            img: Handler-specific image object\n            max_width: Maximum width in pixels\n            max_height: Maximum height in pixels\n\n        Returns:\n            Resized image object\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_dimensions(self, img: Any) -> Tuple[int, int]:\n        \"\"\"\n        Get image dimensions.\n\n        Args:\n            img: Handler-specific image object\n\n        Returns:\n            Tuple of (width, height)\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def to_grayscale(self, img: Any) -> Any:\n        \"\"\"\n        Convert image to grayscale.\n\n        Args:\n            img: Handler-specific image object\n\n        Returns:\n            Grayscale image object\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def gaussian_blur(self, img: Any, sigma: float) -> Any:\n        \"\"\"\n        Apply Gaussian blur to image.\n\n        Args:\n            img: Handler-specific image object\n            sigma: Blur sigma value (0 = no blur)\n\n        Returns:\n            Blurred image object\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def absolute_difference(self, img1: Any, img2: Any) -> Any:\n        \"\"\"\n        Calculate absolute difference between two images.\n\n        Args:\n            img1: First image (handler-specific object)\n            img2: Second image (handler-specific object)\n\n        Returns:\n            Difference image object\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def threshold(self, img: Any, threshold_value: int) -> Tuple[float, Any]:\n        \"\"\"\n        Apply threshold to image and calculate change percentage.\n\n        Args:\n            img: Handler-specific image object (typically grayscale difference)\n            threshold_value: Threshold value (0-255)\n\n        Returns:\n            Tuple of (change_percentage, binary_mask)\n            - change_percentage: Percentage of pixels above threshold (0-100)\n            - binary_mask: Handler-specific binary mask object\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def apply_red_overlay(self, img: Any, mask: Any) -> bytes:\n        \"\"\"\n        Apply red overlay to image where mask is True.\n\n        Args:\n            img: Handler-specific image object (color)\n            mask: Handler-specific binary mask object\n\n        Returns:\n            JPEG bytes with red overlay applied\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def close(self, img: Any) -> None:\n        \"\"\"\n        Clean up image resources if needed.\n\n        Args:\n            img: Handler-specific image object\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def find_template(\n        self,\n        img: Any,\n        template_img: Any,\n        original_bbox: Tuple[int, int, int, int],\n        search_tolerance: float = 0.2\n    ) -> Optional[Tuple[int, int, int, int]]:\n        \"\"\"\n        Find template in image using template matching.\n\n        Args:\n            img: Handler-specific image object to search in\n            template_img: Handler-specific template image object to find\n            original_bbox: Original bounding box (left, top, right, bottom)\n            search_tolerance: How far to search (0.2 = ±20% of region size)\n\n        Returns:\n            New bounding box (left, top, right, bottom) or None if not found\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def save_template(\n        self,\n        img: Any,\n        bbox: Tuple[int, int, int, int],\n        output_path: str\n    ) -> bool:\n        \"\"\"\n        Save a cropped region as a template file.\n\n        Args:\n            img: Handler-specific image object\n            bbox: Bounding box to crop (left, top, right, bottom)\n            output_path: Where to save the template PNG\n\n        Returns:\n            True if successful, False otherwise\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def draw_bounding_box(\n        self,\n        img_bytes: bytes,\n        x: int,\n        y: int,\n        width: int,\n        height: int,\n        color: Tuple[int, int, int] = (255, 0, 0),\n        thickness: int = 3\n    ) -> bytes:\n        \"\"\"\n        Draw a bounding box rectangle on image.\n\n        Args:\n            img_bytes: Image data as bytes\n            x: Left coordinate\n            y: Top coordinate\n            width: Box width\n            height: Box height\n            color: BGR color tuple (default: blue)\n            thickness: Line thickness in pixels\n\n        Returns:\n            Image bytes with bounding box drawn\n        \"\"\"\n        pass\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/image_handler/isolated_libvips.py",
    "content": "\"\"\"\nSubprocess-isolated image operations for memory leak prevention.\n\nLibVIPS accumulates C-level memory in long-running processes that cannot be\nreclaimed by Python's GC or libvips cache management. Using subprocess isolation\nensures complete memory cleanup when the process exits.\n\nThis module wraps LibvipsImageDiffHandler operations in multiprocessing for\ncomplete memory isolation without code duplication.\n\nResearch: https://github.com/libvips/pyvips/issues/234\n\"\"\"\n\nimport multiprocessing\n\n# CRITICAL: Use 'spawn' context instead of 'fork' to avoid inheriting parent's\n# LibVIPS threading state which can cause hangs in gaussblur operations\n# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods\n\n\ndef _worker_generate_diff(conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height):\n    \"\"\"\n    Worker: Generate diff visualization using LibvipsImageDiffHandler in isolated subprocess.\n\n    This runs in a separate process for complete memory isolation.\n    Uses print() instead of loguru to avoid forking issues.\n    \"\"\"\n    try:\n        # Import handler inside worker\n        from .libvips_handler import LibvipsImageDiffHandler\n\n        print(f\"[Worker] Initializing handler\", flush=True)\n        handler = LibvipsImageDiffHandler()\n\n        # Load images using handler\n        img_from = handler.load_from_bytes(img_bytes_from)\n        img_to = handler.load_from_bytes(img_bytes_to)\n\n        # Ensure same size\n        w1, h1 = handler.get_dimensions(img_from)\n        w2, h2 = handler.get_dimensions(img_to)\n        if (w1, h1) != (w2, h2):\n            img_from = handler.resize(img_from, w2, h2)\n\n        # Downscale for faster diff visualization\n        img_from = handler.resize(img_from, max_width, max_height)\n        img_to = handler.resize(img_to, max_width, max_height)\n\n        # Convert to grayscale\n        gray_from = handler.to_grayscale(img_from)\n        gray_to = handler.to_grayscale(img_to)\n\n        # Optional blur - DISABLED due to LibVIPS threading issues in fork\n        # gray_from = handler.gaussian_blur(gray_from, blur_sigma)\n        # gray_to = handler.gaussian_blur(gray_to, blur_sigma)\n\n        # Calculate difference\n        diff = handler.absolute_difference(gray_from, gray_to)\n\n        # Threshold to get mask\n        _, diff_mask = handler.threshold(diff, int(threshold))\n\n        # Generate diff image with red overlay\n        diff_image_bytes = handler.apply_red_overlay(img_to, diff_mask)\n\n        print(f\"[Worker] Generated diff ({len(diff_image_bytes)} bytes)\", flush=True)\n        conn.send(diff_image_bytes)\n\n    except Exception as e:\n        print(f\"[Worker] Error: {e}\", flush=True)\n        import traceback\n        traceback.print_exc()\n        conn.send(None)\n    finally:\n        conn.close()\n\n\ndef generate_diff_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height):\n    \"\"\"\n    Generate diff visualization in isolated subprocess for memory leak prevention.\n\n    Args:\n        img_bytes_from: Previous screenshot bytes\n        img_bytes_to: Current screenshot bytes\n        threshold: Pixel difference threshold\n        blur_sigma: Gaussian blur sigma\n        max_width: Maximum width for diff\n        max_height: Maximum height for diff\n\n    Returns:\n        bytes: JPEG diff image or None on failure\n    \"\"\"\n    ctx = multiprocessing.get_context('spawn')\n    parent_conn, child_conn = ctx.Pipe()\n\n    p = ctx.Process(\n        target=_worker_generate_diff,\n        args=(child_conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height)\n    )\n    p.start()\n\n    result = None\n    try:\n        # Wait for result (30 second timeout)\n        if parent_conn.poll(30):\n            result = parent_conn.recv()\n    except Exception as e:\n        print(f\"[Parent] Error receiving result: {e}\", flush=True)\n    finally:\n        # Always close pipe first\n        try:\n            parent_conn.close()\n        except:\n            pass\n\n        # Try graceful shutdown\n        p.join(timeout=5)\n        if p.is_alive():\n            print(\"[Parent] Process didn't exit gracefully, terminating\", flush=True)\n            p.terminate()\n            p.join(timeout=3)\n\n        # Force kill if still alive\n        if p.is_alive():\n            print(\"[Parent] Process didn't terminate, killing\", flush=True)\n            p.kill()\n            p.join(timeout=1)\n\n    return result\n\n\ndef calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height):\n    \"\"\"\n    Calculate change percentage in isolated subprocess using handler.\n\n    Returns:\n        float: Change percentage\n    \"\"\"\n    ctx = multiprocessing.get_context('spawn')\n    parent_conn, child_conn = ctx.Pipe()\n\n    def _worker_calculate(conn):\n        try:\n            # Import handler inside worker\n            from .libvips_handler import LibvipsImageDiffHandler\n\n            handler = LibvipsImageDiffHandler()\n\n            # Load images\n            img_from = handler.load_from_bytes(img_bytes_from)\n            img_to = handler.load_from_bytes(img_bytes_to)\n\n            # Ensure same size\n            w1, h1 = handler.get_dimensions(img_from)\n            w2, h2 = handler.get_dimensions(img_to)\n            if (w1, h1) != (w2, h2):\n                img_from = handler.resize(img_from, w2, h2)\n\n            # Downscale\n            img_from = handler.resize(img_from, max_width, max_height)\n            img_to = handler.resize(img_to, max_width, max_height)\n\n            # Convert to grayscale\n            gray_from = handler.to_grayscale(img_from)\n            gray_to = handler.to_grayscale(img_to)\n\n            # Optional blur\n            gray_from = handler.gaussian_blur(gray_from, blur_sigma)\n            gray_to = handler.gaussian_blur(gray_to, blur_sigma)\n\n            # Calculate difference\n            diff = handler.absolute_difference(gray_from, gray_to)\n\n            # Threshold and get percentage\n            change_percentage, _ = handler.threshold(diff, int(threshold))\n\n            conn.send(float(change_percentage))\n\n        except Exception as e:\n            print(f\"[Worker] Calculate error: {e}\", flush=True)\n            conn.send(0.0)\n        finally:\n            conn.close()\n\n    p = ctx.Process(target=_worker_calculate, args=(child_conn,))\n    p.start()\n\n    result = 0.0\n    try:\n        if parent_conn.poll(30):\n            result = parent_conn.recv()\n    except Exception as e:\n        print(f\"[Parent] Calculate error receiving result: {e}\", flush=True)\n    finally:\n        # Always close pipe first\n        try:\n            parent_conn.close()\n        except:\n            pass\n\n        # Try graceful shutdown\n        p.join(timeout=5)\n        if p.is_alive():\n            print(\"[Parent] Calculate process didn't exit gracefully, terminating\", flush=True)\n            p.terminate()\n            p.join(timeout=3)\n\n        # Force kill if still alive\n        if p.is_alive():\n            print(\"[Parent] Calculate process didn't terminate, killing\", flush=True)\n            p.kill()\n            p.join(timeout=1)\n\n    return result\n\n\ndef compare_images_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma, min_change_percentage, crop_region=None):\n    \"\"\"\n    Compare images in isolated subprocess for change detection.\n\n    Args:\n        img_bytes_from: Previous screenshot bytes\n        img_bytes_to: Current screenshot bytes\n        threshold: Pixel difference threshold\n        blur_sigma: Gaussian blur sigma\n        min_change_percentage: Minimum percentage to trigger change detection\n        crop_region: Optional tuple (left, top, right, bottom) for cropping both images\n\n    Returns:\n        tuple: (changed_detected, change_percentage)\n    \"\"\"\n    print(f\"[Parent] Starting compare_images_isolated subprocess\", flush=True)\n    ctx = multiprocessing.get_context('spawn')\n    parent_conn, child_conn = ctx.Pipe()\n\n    def _worker_compare(conn):\n        try:\n            print(f\"[Worker] Compare worker starting\", flush=True)\n            # Import handler inside worker\n            from .libvips_handler import LibvipsImageDiffHandler\n\n            print(f\"[Worker] Initializing handler\", flush=True)\n            handler = LibvipsImageDiffHandler()\n\n            # Load images\n            print(f\"[Worker] Loading images (from={len(img_bytes_from)} bytes, to={len(img_bytes_to)} bytes)\", flush=True)\n            img_from = handler.load_from_bytes(img_bytes_from)\n            img_to = handler.load_from_bytes(img_bytes_to)\n            print(f\"[Worker] Images loaded\", flush=True)\n\n            # Crop if region specified\n            if crop_region:\n                print(f\"[Worker] Cropping to region {crop_region}\", flush=True)\n                left, top, right, bottom = crop_region\n                img_from = handler.crop(img_from, left, top, right, bottom)\n                img_to = handler.crop(img_to, left, top, right, bottom)\n                print(f\"[Worker] Cropping completed\", flush=True)\n\n            # Ensure same size\n            w1, h1 = handler.get_dimensions(img_from)\n            w2, h2 = handler.get_dimensions(img_to)\n            print(f\"[Worker] Image dimensions: from={w1}x{h1}, to={w2}x{h2}\", flush=True)\n            if (w1, h1) != (w2, h2):\n                print(f\"[Worker] Resizing to match dimensions\", flush=True)\n                img_from = handler.resize(img_from, w2, h2)\n\n            # Convert to grayscale\n            print(f\"[Worker] Converting to grayscale\", flush=True)\n            gray_from = handler.to_grayscale(img_from)\n            gray_to = handler.to_grayscale(img_to)\n\n            # Optional blur\n            # NOTE: gaussblur can hang in forked subprocesses due to LibVIPS threading\n            # Skip blur as a workaround - sigma=0.8 is subtle and comparison works without it\n            if blur_sigma > 0:\n                print(f\"[Worker] Skipping blur (sigma={blur_sigma}) due to LibVIPS threading issues in fork\", flush=True)\n                # gray_from = handler.gaussian_blur(gray_from, blur_sigma)\n                # gray_to = handler.gaussian_blur(gray_to, blur_sigma)\n\n            # Calculate difference\n            print(f\"[Worker] Calculating difference\", flush=True)\n            diff = handler.absolute_difference(gray_from, gray_to)\n\n            # Threshold and get percentage\n            print(f\"[Worker] Applying threshold ({threshold})\", flush=True)\n            change_percentage, _ = handler.threshold(diff, int(threshold))\n\n            # Determine if change detected\n            changed_detected = change_percentage > min_change_percentage\n\n            print(f\"[Worker] Comparison complete: changed={changed_detected}, percentage={change_percentage:.2f}%\", flush=True)\n            conn.send((changed_detected, float(change_percentage)))\n\n        except Exception as e:\n            print(f\"[Worker] Compare error: {e}\", flush=True)\n            import traceback\n            traceback.print_exc()\n            conn.send((False, 0.0))\n        finally:\n            conn.close()\n\n    p = ctx.Process(target=_worker_compare, args=(child_conn,))\n    print(f\"[Parent] Starting subprocess (pid will be assigned)\", flush=True)\n    p.start()\n    print(f\"[Parent] Subprocess started (pid={p.pid}), waiting for result (30s timeout)\", flush=True)\n\n    result = (False, 0.0)\n    try:\n        if parent_conn.poll(30):\n            print(f\"[Parent] Result available, receiving\", flush=True)\n            result = parent_conn.recv()\n            print(f\"[Parent] Result received: {result}\", flush=True)\n        else:\n            print(f\"[Parent] Timeout waiting for result after 30s\", flush=True)\n    except Exception as e:\n        print(f\"[Parent] Compare error receiving result: {e}\", flush=True)\n    finally:\n        # Always close pipe first\n        try:\n            parent_conn.close()\n        except:\n            pass\n\n        # Try graceful shutdown\n        import time\n        print(f\"[Parent] Waiting for subprocess to exit (5s timeout)\", flush=True)\n        join_start = time.time()\n        p.join(timeout=5)\n        join_elapsed = time.time() - join_start\n        print(f\"[Parent] First join took {join_elapsed:.2f}s\", flush=True)\n\n        if p.is_alive():\n            print(\"[Parent] Compare process didn't exit gracefully, terminating\", flush=True)\n            term_start = time.time()\n            p.terminate()\n            p.join(timeout=3)\n            term_elapsed = time.time() - term_start\n            print(f\"[Parent] Terminate+join took {term_elapsed:.2f}s\", flush=True)\n\n        # Force kill if still alive\n        if p.is_alive():\n            print(\"[Parent] Compare process didn't terminate, killing\", flush=True)\n            kill_start = time.time()\n            p.kill()\n            p.join(timeout=1)\n            kill_elapsed = time.time() - kill_start\n            print(f\"[Parent] Kill+join took {kill_elapsed:.2f}s\", flush=True)\n\n        print(f\"[Parent] Subprocess cleanup complete, returning result\", flush=True)\n\n    return result\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/image_handler/isolated_opencv.py",
    "content": "\"\"\"\nOpenCV-based subprocess isolation for image comparison.\n\nOpenCV is much more stable in multiprocessing contexts than LibVIPS.\nNo threading issues, no fork problems, picklable functions.\n\"\"\"\n\nimport multiprocessing\nimport numpy as np\nfrom .. import POLL_TIMEOUT_ABSOLUTE\n\n# Public implementation name for logging\nIMPLEMENTATION_NAME = \"OpenCV\"\n\n\ndef _worker_compare(conn, img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, crop_region):\n    \"\"\"\n    Worker function for image comparison (must be top-level for pickling with spawn).\n\n    Args:\n        conn: Pipe connection for sending results\n        img_bytes_from: Previous screenshot bytes\n        img_bytes_to: Current screenshot bytes\n        pixel_difference_threshold: Pixel-level sensitivity (0-255) - how different must a pixel be to count as changed\n        blur_sigma: Gaussian blur sigma\n        crop_region: Optional (left, top, right, bottom) crop coordinates\n    \"\"\"\n    import time\n    try:\n        import cv2\n\n        # CRITICAL: Disable OpenCV threading to prevent thread explosion\n        # With multiprocessing, each subprocess would otherwise spawn threads equal to CPU cores\n        # This causes excessive thread counts and memory overhead\n        # Research: https://medium.com/@rachittayal7/a-note-on-opencv-threads-performance-in-prod-d10180716fba\n        cv2.setNumThreads(1)\n\n        print(f\"[{time.time():.3f}] [Worker] Compare worker starting (threads=1 for memory optimization)\", flush=True)\n\n        # Decode images from bytes\n        print(f\"[{time.time():.3f}] [Worker] Loading images (from={len(img_bytes_from)} bytes, to={len(img_bytes_to)} bytes)\", flush=True)\n        img_from = cv2.imdecode(np.frombuffer(img_bytes_from, np.uint8), cv2.IMREAD_COLOR)\n        img_to = cv2.imdecode(np.frombuffer(img_bytes_to, np.uint8), cv2.IMREAD_COLOR)\n\n        # Check if decoding succeeded\n        if img_from is None:\n            raise ValueError(\"Failed to decode 'from' image - may be corrupt or unsupported format\")\n        if img_to is None:\n            raise ValueError(\"Failed to decode 'to' image - may be corrupt or unsupported format\")\n\n        print(f\"[{time.time():.3f}] [Worker] Images loaded: from={img_from.shape}, to={img_to.shape}\", flush=True)\n\n        # Crop if region specified\n        if crop_region:\n            print(f\"[{time.time():.3f}] [Worker] Cropping to region {crop_region}\", flush=True)\n            left, top, right, bottom = crop_region\n            img_from = img_from[top:bottom, left:right]\n            img_to = img_to[top:bottom, left:right]\n            print(f\"[{time.time():.3f}] [Worker] Cropped: from={img_from.shape}, to={img_to.shape}\", flush=True)\n\n        # Resize if dimensions don't match\n        if img_from.shape != img_to.shape:\n            print(f\"[{time.time():.3f}] [Worker] Resizing to match dimensions\", flush=True)\n            img_from = cv2.resize(img_from, (img_to.shape[1], img_to.shape[0]))\n\n        # Convert to grayscale\n        print(f\"[{time.time():.3f}] [Worker] Converting to grayscale\", flush=True)\n        gray_from = cv2.cvtColor(img_from, cv2.COLOR_BGR2GRAY)\n        gray_to = cv2.cvtColor(img_to, cv2.COLOR_BGR2GRAY)\n\n        # Optional Gaussian blur\n        if blur_sigma > 0:\n            print(f\"[{time.time():.3f}] [Worker] Applying Gaussian blur (sigma={blur_sigma})\", flush=True)\n            # OpenCV uses kernel size, convert sigma to kernel size: size = 2 * round(3*sigma) + 1\n            ksize = int(2 * round(3 * blur_sigma)) + 1\n            if ksize % 2 == 0:  # Must be odd\n                ksize += 1\n            gray_from = cv2.GaussianBlur(gray_from, (ksize, ksize), blur_sigma)\n            gray_to = cv2.GaussianBlur(gray_to, (ksize, ksize), blur_sigma)\n            print(f\"[{time.time():.3f}] [Worker] Blur applied (kernel={ksize}x{ksize})\", flush=True)\n\n        # Calculate absolute difference\n        print(f\"[{time.time():.3f}] [Worker] Calculating absolute difference\", flush=True)\n        diff = cv2.absdiff(gray_from, gray_to)\n\n        # Apply threshold\n        print(f\"[{time.time():.3f}] [Worker] Applying pixel difference threshold ({pixel_difference_threshold})\", flush=True)\n        _, thresholded = cv2.threshold(diff, int(pixel_difference_threshold), 255, cv2.THRESH_BINARY)\n\n        # Calculate change percentage\n        total_pixels = thresholded.size\n        changed_pixels = np.count_nonzero(thresholded)\n        change_percentage = (changed_pixels / total_pixels) * 100.0\n\n        print(f\"[{time.time():.3f}] [Worker] Comparison complete: percentage={change_percentage:.2f}%\", flush=True)\n        # Return only the score - let the caller decide if it's a \"change\"\n        conn.send(float(change_percentage))\n\n    except Exception as e:\n        print(f\"[{time.time():.3f}] [Worker] Error: {e}\", flush=True)\n        import traceback\n        traceback.print_exc()\n        # Send error info as dict so parent can re-raise\n        conn.send({'error': str(e), 'traceback': traceback.format_exc()})\n    finally:\n        conn.close()\n\n\nasync def compare_images_isolated(img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, crop_region=None):\n    \"\"\"\n    Compare images in isolated subprocess using OpenCV (async-safe).\n\n    Args:\n        img_bytes_from: Previous screenshot bytes\n        img_bytes_to: Current screenshot bytes\n        pixel_difference_threshold: Pixel-level sensitivity (0-255) - how different must a pixel be to count as changed\n        blur_sigma: Gaussian blur sigma\n        crop_region: Optional (left, top, right, bottom) crop coordinates\n\n    Returns:\n        float: Change percentage (0-100)\n    \"\"\"\n    import time\n    import asyncio\n    print(f\"[{time.time():.3f}] [Parent] Starting OpenCV comparison subprocess\", flush=True)\n\n    # Use spawn method for clean process (no fork issues)\n    ctx = multiprocessing.get_context('spawn')\n    parent_conn, child_conn = ctx.Pipe()\n\n    p = ctx.Process(\n        target=_worker_compare,\n        args=(child_conn, img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, crop_region)\n    )\n\n    print(f\"[{time.time():.3f}] [Parent] Starting subprocess\", flush=True)\n    p.start()\n    print(f\"[{time.time():.3f}] [Parent] Subprocess started (pid={p.pid}), waiting for result ({POLL_TIMEOUT_ABSOLUTE}s timeout)\", flush=True)\n\n    result = 0.0\n    try:\n        # Async-friendly polling: check in small intervals without blocking event loop\n        deadline = time.time() + POLL_TIMEOUT_ABSOLUTE\n        while time.time() < deadline:\n            # Run poll() in thread to avoid blocking event loop\n            has_data = await asyncio.to_thread(parent_conn.poll, 0.1)\n            if has_data:\n                print(f\"[{time.time():.3f}] [Parent] Result available, receiving\", flush=True)\n                result = await asyncio.to_thread(parent_conn.recv)\n                # Check if result is an error dict\n                if isinstance(result, dict) and 'error' in result:\n                    raise RuntimeError(f\"Image comparison failed: {result['error']}\")\n                print(f\"[{time.time():.3f}] [Parent] Result received: {result:.2f}%\", flush=True)\n                break\n            await asyncio.sleep(0)  # Yield control to event loop\n        else:\n            from loguru import logger\n            logger.critical(f\"[OpenCV subprocess] Timeout waiting for compare_images result after {POLL_TIMEOUT_ABSOLUTE}s (subprocess may be hung)\")\n            print(f\"[{time.time():.3f}] [Parent] Timeout waiting for result after {POLL_TIMEOUT_ABSOLUTE}s\", flush=True)\n            raise TimeoutError(f\"Image comparison subprocess timeout after {POLL_TIMEOUT_ABSOLUTE}s\")\n    except Exception as e:\n        print(f\"[{time.time():.3f}] [Parent] Error receiving result: {e}\", flush=True)\n        raise\n    finally:\n        # Always close pipe first\n        try:\n            parent_conn.close()\n        except:\n            pass\n\n        # Try graceful shutdown (async-safe)\n        print(f\"[{time.time():.3f}] [Parent] Waiting for subprocess to exit (5s timeout)\", flush=True)\n        join_start = time.time()\n        await asyncio.to_thread(p.join, 5)\n        join_elapsed = time.time() - join_start\n        print(f\"[{time.time():.3f}] [Parent] First join took {join_elapsed:.2f}s\", flush=True)\n\n        if p.is_alive():\n            print(f\"[{time.time():.3f}] [Parent] Process didn't exit gracefully, terminating\", flush=True)\n            term_start = time.time()\n            p.terminate()\n            await asyncio.to_thread(p.join, 3)\n            term_elapsed = time.time() - term_start\n            print(f\"[{time.time():.3f}] [Parent] Terminate+join took {term_elapsed:.2f}s\", flush=True)\n\n        # Force kill if still alive\n        if p.is_alive():\n            print(f\"[{time.time():.3f}] [Parent] Process didn't terminate, killing\", flush=True)\n            kill_start = time.time()\n            p.kill()\n            await asyncio.to_thread(p.join, 1)\n            kill_elapsed = time.time() - kill_start\n            print(f\"[{time.time():.3f}] [Parent] Kill+join took {kill_elapsed:.2f}s\", flush=True)\n\n        print(f\"[{time.time():.3f}] [Parent] Subprocess cleanup complete, returning result\", flush=True)\n\n    return result\n\n\ndef _worker_generate_diff(conn, img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, max_width, max_height):\n    \"\"\"\n    Worker function for generating visual diff with red overlay.\n    \"\"\"\n    import time\n    try:\n        import cv2\n\n        cv2.setNumThreads(1)\n        print(f\"[{time.time():.3f}] [Worker] Generate diff worker starting\", flush=True)\n\n        # Decode images\n        img_from = cv2.imdecode(np.frombuffer(img_bytes_from, np.uint8), cv2.IMREAD_COLOR)\n        img_to = cv2.imdecode(np.frombuffer(img_bytes_to, np.uint8), cv2.IMREAD_COLOR)\n\n        # Resize if needed to match dimensions\n        if img_from.shape != img_to.shape:\n            img_from = cv2.resize(img_from, (img_to.shape[1], img_to.shape[0]))\n\n        # Downscale to max dimensions for faster processing\n        h, w = img_to.shape[:2]\n        if w > max_width or h > max_height:\n            scale = min(max_width / w, max_height / h)\n            new_w = int(w * scale)\n            new_h = int(h * scale)\n            img_from = cv2.resize(img_from, (new_w, new_h))\n            img_to = cv2.resize(img_to, (new_w, new_h))\n\n        # Convert to grayscale\n        gray_from = cv2.cvtColor(img_from, cv2.COLOR_BGR2GRAY)\n        gray_to = cv2.cvtColor(img_to, cv2.COLOR_BGR2GRAY)\n\n        # Optional blur\n        if blur_sigma > 0:\n            ksize = int(2 * round(3 * blur_sigma)) + 1\n            if ksize % 2 == 0:\n                ksize += 1\n            gray_from = cv2.GaussianBlur(gray_from, (ksize, ksize), blur_sigma)\n            gray_to = cv2.GaussianBlur(gray_to, (ksize, ksize), blur_sigma)\n\n        # Calculate difference\n        diff = cv2.absdiff(gray_from, gray_to)\n\n        # Apply threshold to get mask\n        _, mask = cv2.threshold(diff, int(pixel_difference_threshold), 255, cv2.THRESH_BINARY)\n\n        # Create red overlay on original 'to' image\n        # Where mask is 255 (changed), blend 50% red\n        overlay = img_to.copy()\n        overlay[:, :, 2] = np.where(mask > 0,\n                                     np.clip(overlay[:, :, 2] * 0.5 + 127, 0, 255).astype(np.uint8),\n                                     overlay[:, :, 2])\n        overlay[:, :, 0:2] = np.where(mask[:, :, np.newaxis] > 0,\n                                       (overlay[:, :, 0:2] * 0.5).astype(np.uint8),\n                                       overlay[:, :, 0:2])\n\n        # Encode as JPEG\n        _, encoded = cv2.imencode('.jpg', overlay, [cv2.IMWRITE_JPEG_QUALITY, 85])\n        diff_bytes = encoded.tobytes()\n\n        print(f\"[{time.time():.3f}] [Worker] Generated diff ({len(diff_bytes)} bytes)\", flush=True)\n        conn.send(diff_bytes)\n\n    except Exception as e:\n        print(f\"[{time.time():.3f}] [Worker] Generate diff error: {e}\", flush=True)\n        import traceback\n        traceback.print_exc()\n        # Send error info as dict so parent can re-raise\n        conn.send({'error': str(e), 'traceback': traceback.format_exc()})\n    finally:\n        conn.close()\n\n\nasync def generate_diff_isolated(img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, max_width, max_height):\n    \"\"\"\n    Generate visual diff with red overlay in isolated subprocess (async-safe).\n\n    Returns:\n        bytes: JPEG diff image or None on failure\n    \"\"\"\n    import time\n    import asyncio\n    print(f\"[{time.time():.3f}] [Parent] Starting generate_diff subprocess\", flush=True)\n\n    ctx = multiprocessing.get_context('spawn')\n    parent_conn, child_conn = ctx.Pipe()\n\n    p = ctx.Process(\n        target=_worker_generate_diff,\n        args=(child_conn, img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, max_width, max_height)\n    )\n\n    print(f\"[{time.time():.3f}] [Parent] Starting subprocess\", flush=True)\n    p.start()\n    print(f\"[{time.time():.3f}] [Parent] Subprocess started (pid={p.pid}), waiting for result ({POLL_TIMEOUT_ABSOLUTE}s timeout)\", flush=True)\n\n    result = None\n    try:\n        # Async-friendly polling: check in small intervals without blocking event loop\n        deadline = time.time() + POLL_TIMEOUT_ABSOLUTE\n        while time.time() < deadline:\n            # Run poll() in thread to avoid blocking event loop\n            has_data = await asyncio.to_thread(parent_conn.poll, 0.1)\n            if has_data:\n                print(f\"[{time.time():.3f}] [Parent] Result available, receiving\", flush=True)\n                result = await asyncio.to_thread(parent_conn.recv)\n                # Check if result is an error dict\n                if isinstance(result, dict) and 'error' in result:\n                    raise RuntimeError(f\"Generate diff failed: {result['error']}\")\n                print(f\"[{time.time():.3f}] [Parent] Result received ({len(result) if result else 0} bytes)\", flush=True)\n                break\n            await asyncio.sleep(0)  # Yield control to event loop\n        else:\n            from loguru import logger\n            logger.critical(f\"[OpenCV subprocess] Timeout waiting for generate_diff result after {POLL_TIMEOUT_ABSOLUTE}s (subprocess may be hung)\")\n            print(f\"[{time.time():.3f}] [Parent] Timeout waiting for result after {POLL_TIMEOUT_ABSOLUTE}s\", flush=True)\n            raise TimeoutError(f\"Generate diff subprocess timeout after {POLL_TIMEOUT_ABSOLUTE}s\")\n    except Exception as e:\n        print(f\"[{time.time():.3f}] [Parent] Error receiving diff: {e}\", flush=True)\n        raise\n    finally:\n        # Always close pipe first\n        try:\n            parent_conn.close()\n        except:\n            pass\n\n        # Try graceful shutdown (async-safe)\n        print(f\"[{time.time():.3f}] [Parent] Waiting for subprocess to exit (5s timeout)\", flush=True)\n        join_start = time.time()\n        await asyncio.to_thread(p.join, 5)\n        join_elapsed = time.time() - join_start\n        print(f\"[{time.time():.3f}] [Parent] First join took {join_elapsed:.2f}s\", flush=True)\n\n        if p.is_alive():\n            print(f\"[{time.time():.3f}] [Parent] Process didn't exit gracefully, terminating\", flush=True)\n            term_start = time.time()\n            p.terminate()\n            await asyncio.to_thread(p.join, 3)\n            term_elapsed = time.time() - term_start\n            print(f\"[{time.time():.3f}] [Parent] Terminate+join took {term_elapsed:.2f}s\", flush=True)\n\n        if p.is_alive():\n            print(f\"[{time.time():.3f}] [Parent] Process didn't terminate, killing\", flush=True)\n            kill_start = time.time()\n            p.kill()\n            await asyncio.to_thread(p.join, 1)\n            kill_elapsed = time.time() - kill_start\n            print(f\"[{time.time():.3f}] [Parent] Kill+join took {kill_elapsed:.2f}s\", flush=True)\n\n        print(f\"[{time.time():.3f}] [Parent] Subprocess cleanup complete, returning result\", flush=True)\n\n    return result\n\n\ndef _worker_draw_bounding_box(conn, img_bytes, x, y, width, height, color, thickness):\n    \"\"\"\n    Worker function for drawing bounding box on image.\n    \"\"\"\n    import time\n    try:\n        import cv2\n\n        cv2.setNumThreads(1)\n        print(f\"[{time.time():.3f}] [Worker] Draw bounding box worker starting\", flush=True)\n\n        # Decode image\n        img = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR)\n        if img is None:\n            print(f\"[{time.time():.3f}] [Worker] Failed to decode image\", flush=True)\n            conn.send(None)\n            return\n\n        # Draw rectangle (BGR format)\n        cv2.rectangle(img, (x, y), (x + width, y + height), color, thickness)\n\n        # Encode back to PNG\n        _, encoded = cv2.imencode('.png', img)\n        result_bytes = encoded.tobytes()\n\n        print(f\"[{time.time():.3f}] [Worker] Bounding box drawn ({len(result_bytes)} bytes)\", flush=True)\n        conn.send(result_bytes)\n\n    except Exception as e:\n        print(f\"[{time.time():.3f}] [Worker] Draw bounding box error: {e}\", flush=True)\n        import traceback\n        traceback.print_exc()\n        # Send error info as dict so parent can re-raise\n        conn.send({'error': str(e), 'traceback': traceback.format_exc()})\n    finally:\n        conn.close()\n\n\nasync def draw_bounding_box_isolated(img_bytes, x, y, width, height, color=(255, 0, 0), thickness=3):\n    \"\"\"\n    Draw bounding box on image in isolated subprocess (async-safe).\n\n    Args:\n        img_bytes: Image data as bytes\n        x: Left coordinate\n        y: Top coordinate\n        width: Box width\n        height: Box height\n        color: BGR color tuple (default: blue)\n        thickness: Line thickness in pixels\n\n    Returns:\n        bytes: PNG image with bounding box or None on failure\n    \"\"\"\n    import time\n    import asyncio\n    print(f\"[{time.time():.3f}] [Parent] Starting draw_bounding_box subprocess\", flush=True)\n\n    ctx = multiprocessing.get_context('spawn')\n    parent_conn, child_conn = ctx.Pipe()\n\n    p = ctx.Process(\n        target=_worker_draw_bounding_box,\n        args=(child_conn, img_bytes, x, y, width, height, color, thickness)\n    )\n\n    print(f\"[{time.time():.3f}] [Parent] Starting subprocess\", flush=True)\n    p.start()\n    print(f\"[{time.time():.3f}] [Parent] Subprocess started (pid={p.pid}), waiting for result ({POLL_TIMEOUT_ABSOLUTE}s timeout)\", flush=True)\n\n    result = None\n    try:\n        # Async-friendly polling: check in small intervals without blocking event loop\n        deadline = time.time() + POLL_TIMEOUT_ABSOLUTE\n        while time.time() < deadline:\n            # Run poll() in thread to avoid blocking event loop\n            has_data = await asyncio.to_thread(parent_conn.poll, 0.1)\n            if has_data:\n                print(f\"[{time.time():.3f}] [Parent] Result available, receiving\", flush=True)\n                # Run recv() in thread too\n                result = await asyncio.to_thread(parent_conn.recv)\n                # Check if result is an error dict\n                if isinstance(result, dict) and 'error' in result:\n                    raise RuntimeError(f\"Draw bounding box failed: {result['error']}\")\n                print(f\"[{time.time():.3f}] [Parent] Result received ({len(result) if result else 0} bytes)\", flush=True)\n                break\n            # Yield control to event loop\n            await asyncio.sleep(0)\n        else:\n            from loguru import logger\n            logger.critical(f\"[OpenCV subprocess] Timeout waiting for draw_bounding_box result after {POLL_TIMEOUT_ABSOLUTE}s (subprocess may be hung)\")\n            print(f\"[{time.time():.3f}] [Parent] Timeout waiting for result after {POLL_TIMEOUT_ABSOLUTE}s\", flush=True)\n            raise TimeoutError(f\"Draw bounding box subprocess timeout after {POLL_TIMEOUT_ABSOLUTE}s\")\n    except Exception as e:\n        print(f\"[{time.time():.3f}] [Parent] Error receiving result: {e}\", flush=True)\n        raise\n    finally:\n        # Always close pipe first\n        try:\n            parent_conn.close()\n        except:\n            pass\n\n        # Try graceful shutdown (run join in thread to avoid blocking)\n        print(f\"[{time.time():.3f}] [Parent] Waiting for subprocess to exit (3s timeout)\", flush=True)\n        join_start = time.time()\n        await asyncio.to_thread(p.join, 3)\n        join_elapsed = time.time() - join_start\n        print(f\"[{time.time():.3f}] [Parent] First join took {join_elapsed:.2f}s\", flush=True)\n\n        if p.is_alive():\n            print(f\"[{time.time():.3f}] [Parent] Process didn't exit gracefully, terminating\", flush=True)\n            term_start = time.time()\n            p.terminate()\n            await asyncio.to_thread(p.join, 2)\n            term_elapsed = time.time() - term_start\n            print(f\"[{time.time():.3f}] [Parent] Terminate+join took {term_elapsed:.2f}s\", flush=True)\n\n        if p.is_alive():\n            print(f\"[{time.time():.3f}] [Parent] Process didn't terminate, killing\", flush=True)\n            kill_start = time.time()\n            p.kill()\n            await asyncio.to_thread(p.join, 1)\n            kill_elapsed = time.time() - kill_start\n            print(f\"[{time.time():.3f}] [Parent] Kill+join took {kill_elapsed:.2f}s\", flush=True)\n\n        print(f\"[{time.time():.3f}] [Parent] Subprocess cleanup complete, returning result\", flush=True)\n\n    return result\n\n\ndef _worker_calculate_percentage(conn, img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, max_width, max_height):\n    \"\"\"\n    Worker function for calculating change percentage.\n    \"\"\"\n    import time\n    try:\n        import cv2\n\n        cv2.setNumThreads(1)\n\n        # Decode images\n        img_from = cv2.imdecode(np.frombuffer(img_bytes_from, np.uint8), cv2.IMREAD_COLOR)\n        img_to = cv2.imdecode(np.frombuffer(img_bytes_to, np.uint8), cv2.IMREAD_COLOR)\n\n        # Resize if needed\n        if img_from.shape != img_to.shape:\n            img_from = cv2.resize(img_from, (img_to.shape[1], img_to.shape[0]))\n\n        # Downscale to max dimensions\n        h, w = img_to.shape[:2]\n        if w > max_width or h > max_height:\n            scale = min(max_width / w, max_height / h)\n            new_w = int(w * scale)\n            new_h = int(h * scale)\n            img_from = cv2.resize(img_from, (new_w, new_h))\n            img_to = cv2.resize(img_to, (new_w, new_h))\n\n        # Convert to grayscale\n        gray_from = cv2.cvtColor(img_from, cv2.COLOR_BGR2GRAY)\n        gray_to = cv2.cvtColor(img_to, cv2.COLOR_BGR2GRAY)\n\n        # Optional blur\n        if blur_sigma > 0:\n            ksize = int(2 * round(3 * blur_sigma)) + 1\n            if ksize % 2 == 0:\n                ksize += 1\n            gray_from = cv2.GaussianBlur(gray_from, (ksize, ksize), blur_sigma)\n            gray_to = cv2.GaussianBlur(gray_to, (ksize, ksize), blur_sigma)\n\n        # Calculate difference\n        diff = cv2.absdiff(gray_from, gray_to)\n\n        # Apply threshold\n        _, thresholded = cv2.threshold(diff, int(pixel_difference_threshold), 255, cv2.THRESH_BINARY)\n\n        # Calculate percentage\n        total_pixels = thresholded.size\n        changed_pixels = np.count_nonzero(thresholded)\n        change_percentage = (changed_pixels / total_pixels) * 100.0\n\n        conn.send(float(change_percentage))\n\n    except Exception as e:\n        print(f\"[{time.time():.3f}] [Worker] Calculate percentage error: {e}\", flush=True)\n        import traceback\n        traceback.print_exc()\n        # Send error info as dict so parent can re-raise\n        conn.send({'error': str(e), 'traceback': traceback.format_exc()})\n    finally:\n        conn.close()\n\n\nasync def calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, max_width, max_height):\n    \"\"\"\n    Calculate change percentage in isolated subprocess (async-safe).\n\n    Returns:\n        float: Change percentage\n    \"\"\"\n    import time\n    import asyncio\n    print(f\"[{time.time():.3f}] [Parent] Starting calculate_percentage subprocess\", flush=True)\n\n    ctx = multiprocessing.get_context('spawn')\n    parent_conn, child_conn = ctx.Pipe()\n\n    p = ctx.Process(\n        target=_worker_calculate_percentage,\n        args=(child_conn, img_bytes_from, img_bytes_to, pixel_difference_threshold, blur_sigma, max_width, max_height)\n    )\n\n    print(f\"[{time.time():.3f}] [Parent] Starting subprocess\", flush=True)\n    p.start()\n    print(f\"[{time.time():.3f}] [Parent] Subprocess started (pid={p.pid}), waiting for result ({POLL_TIMEOUT_ABSOLUTE}s timeout)\", flush=True)\n\n    result = 0.0\n    try:\n        # Async-friendly polling: check in small intervals without blocking event loop\n        deadline = time.time() + POLL_TIMEOUT_ABSOLUTE\n        while time.time() < deadline:\n            # Run poll() in thread to avoid blocking event loop\n            has_data = await asyncio.to_thread(parent_conn.poll, 0.1)\n            if has_data:\n                print(f\"[{time.time():.3f}] [Parent] Result available, receiving\", flush=True)\n                result = await asyncio.to_thread(parent_conn.recv)\n                # Check if result is an error dict\n                if isinstance(result, dict) and 'error' in result:\n                    raise RuntimeError(f\"Calculate change percentage failed: {result['error']}\")\n                print(f\"[{time.time():.3f}] [Parent] Result received: {result:.2f}%\", flush=True)\n                break\n            await asyncio.sleep(0)  # Yield control to event loop\n        else:\n            from loguru import logger\n            logger.critical(f\"[OpenCV subprocess] Timeout waiting for calculate_change_percentage result after {POLL_TIMEOUT_ABSOLUTE}s (subprocess may be hung)\")\n            print(f\"[{time.time():.3f}] [Parent] Timeout waiting for result after {POLL_TIMEOUT_ABSOLUTE}s\", flush=True)\n            raise TimeoutError(f\"Calculate change percentage subprocess timeout after {POLL_TIMEOUT_ABSOLUTE}s\")\n    except Exception as e:\n        print(f\"[{time.time():.3f}] [Parent] Error receiving percentage: {e}\", flush=True)\n        raise\n    finally:\n        # Always close pipe first\n        try:\n            parent_conn.close()\n        except:\n            pass\n\n        # Try graceful shutdown (async-safe)\n        print(f\"[{time.time():.3f}] [Parent] Waiting for subprocess to exit (5s timeout)\", flush=True)\n        join_start = time.time()\n        await asyncio.to_thread(p.join, 5)\n        join_elapsed = time.time() - join_start\n        print(f\"[{time.time():.3f}] [Parent] First join took {join_elapsed:.2f}s\", flush=True)\n\n        if p.is_alive():\n            print(f\"[{time.time():.3f}] [Parent] Process didn't exit gracefully, terminating\", flush=True)\n            term_start = time.time()\n            p.terminate()\n            await asyncio.to_thread(p.join, 3)\n            term_elapsed = time.time() - term_start\n            print(f\"[{time.time():.3f}] [Parent] Terminate+join took {term_elapsed:.2f}s\", flush=True)\n\n        if p.is_alive():\n            print(f\"[{time.time():.3f}] [Parent] Process didn't terminate, killing\", flush=True)\n            kill_start = time.time()\n            p.kill()\n            await asyncio.to_thread(p.join, 1)\n            kill_elapsed = time.time() - kill_start\n            print(f\"[{time.time():.3f}] [Parent] Kill+join took {kill_elapsed:.2f}s\", flush=True)\n\n        print(f\"[{time.time():.3f}] [Parent] Subprocess cleanup complete, returning result\", flush=True)\n\n    return result\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/image_handler/libvips_handler.py",
    "content": "\"\"\"\nLibVIPS implementation of ImageDiffHandler.\n\nUses pyvips for high-performance image processing with streaming architecture\nand low memory footprint. Ideal for large screenshots (8000px+).\n\"\"\"\n\nfrom __future__ import annotations\nimport os\nfrom typing import Tuple, Any, TYPE_CHECKING\nfrom loguru import logger\n\nif TYPE_CHECKING:\n    import pyvips\n\ntry:\n    import pyvips\n    PYVIPS_AVAILABLE = True\nexcept ImportError:\n    PYVIPS_AVAILABLE = False\n    logger.warning(\"pyvips not available - install with: pip install pyvips\")\n\nfrom . import ImageDiffHandler\n\n\nclass LibvipsImageDiffHandler(ImageDiffHandler):\n    \"\"\"\n    LibVIPS implementation using streaming architecture.\n\n    Benefits:\n    - 3x faster than ImageMagick\n    - 5x less memory than PIL\n    - Automatic multi-threading\n    - Streaming - processes images in chunks\n    \"\"\"\n\n    def __init__(self):\n        if not PYVIPS_AVAILABLE:\n            raise ImportError(\"pyvips is not installed. Install with: pip install pyvips\")\n\n    def load_from_bytes(self, img_bytes: bytes) -> pyvips.Image:\n        \"\"\"Load image from bytes using libvips streaming.\"\"\"\n        return pyvips.Image.new_from_buffer(img_bytes, '')\n\n    def save_to_bytes(self, img: pyvips.Image, format: str = 'png', quality: int = 85) -> bytes:\n        \"\"\"\n        Save image to bytes using temp file.\n\n        Note: Uses temp file instead of write_to_buffer() to avoid C memory leak.\n        See: https://github.com/libvips/pyvips/issues/234\n        \"\"\"\n        import tempfile\n\n        format = format.lower()\n\n        try:\n            if format == 'png':\n                suffix = '.png'\n                write_args = {'compression': 6}\n            elif format in ['jpg', 'jpeg']:\n                suffix = '.jpg'\n                write_args = {'Q': quality}\n            else:\n                raise ValueError(f\"Unsupported format: {format}\")\n\n            # Use temp file to avoid write_to_buffer() memory leak\n            with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:\n                temp_path = tmp.name\n\n            # Write to file\n            img.write_to_file(temp_path, **write_args)\n\n            # Read bytes and clean up\n            with open(temp_path, 'rb') as f:\n                image_bytes = f.read()\n\n            os.unlink(temp_path)\n            return image_bytes\n\n        except Exception as e:\n            logger.error(f\"Failed to save via temp file: {e}\")\n            # Fallback to write_to_buffer if temp file fails\n            if format == 'png':\n                return img.write_to_buffer('.png', compression=6)\n            else:\n                return img.write_to_buffer('.jpg', Q=quality)\n\n    def crop(self, img: pyvips.Image, left: int, top: int, right: int, bottom: int) -> pyvips.Image:\n        \"\"\"Crop image using libvips.\"\"\"\n        width = right - left\n        height = bottom - top\n        return img.crop(left, top, width, height)\n\n    def resize(self, img: pyvips.Image, max_width: int, max_height: int) -> pyvips.Image:\n        \"\"\"\n        Resize image maintaining aspect ratio.\n\n        Uses thumbnail_image for efficient downscaling with streaming.\n        \"\"\"\n        width, height = img.width, img.height\n\n        if width <= max_width and height <= max_height:\n            return img\n\n        # Calculate scaling to fit within max dimensions\n        width_ratio = max_width / width if width > max_width else 1.0\n        height_ratio = max_height / height if height > max_height else 1.0\n        ratio = min(width_ratio, height_ratio)\n\n        new_width = int(width * ratio)\n        new_height = int(height * ratio)\n\n        logger.debug(f\"Resizing image: {width}x{height} -> {new_width}x{new_height}\")\n\n        # thumbnail_image is faster than resize for downscaling\n        return img.thumbnail_image(new_width, height=new_height)\n\n    def get_dimensions(self, img: pyvips.Image) -> Tuple[int, int]:\n        \"\"\"Get image dimensions.\"\"\"\n        return (img.width, img.height)\n\n    def to_grayscale(self, img: pyvips.Image) -> pyvips.Image:\n        \"\"\"Convert to grayscale using 'b-w' colorspace.\"\"\"\n        return img.colourspace('b-w')\n\n    def gaussian_blur(self, img: pyvips.Image, sigma: float) -> pyvips.Image:\n        \"\"\"Apply Gaussian blur.\"\"\"\n        if sigma > 0:\n            return img.gaussblur(sigma)\n        return img\n\n    def absolute_difference(self, img1: pyvips.Image, img2: pyvips.Image) -> pyvips.Image:\n        \"\"\"\n        Calculate absolute difference using operator overloading.\n\n        LibVIPS supports arithmetic operations between images.\n        \"\"\"\n        return (img1 - img2).abs()\n\n    def threshold(self, img: pyvips.Image, threshold_value: int) -> Tuple[float, pyvips.Image]:\n        \"\"\"\n        Apply threshold and calculate change percentage.\n\n        Uses ifthenelse for efficient thresholding.\n        \"\"\"\n        # Create binary mask: pixels above threshold = 255, others = 0\n        mask = (img > threshold_value).ifthenelse(255, 0)\n\n        # Calculate percentage by averaging mask values\n        # avg() returns mean pixel value (0-255)\n        # Divide by 255 to get proportion, multiply by 100 for percentage\n        mean_value = mask.avg()\n        change_percentage = (mean_value / 255.0) * 100.0\n\n        return float(change_percentage), mask\n\n    def apply_red_overlay(self, img: pyvips.Image, mask: pyvips.Image) -> bytes:\n        \"\"\"\n        Apply red overlay where mask is True (50% blend).\n\n        Args:\n            img: Color image (will be converted to RGB if needed)\n            mask: Binary mask (255 where changed, 0 elsewhere)\n\n        Returns:\n            JPEG bytes with red overlay\n        \"\"\"\n        import tempfile\n\n        # Ensure RGB colorspace\n        if img.bands == 1:\n            img = img.colourspace('srgb')\n\n        # Normalize mask to 0-1 range for blending\n        mask_normalized = mask / 255.0\n\n        # Split into R, G, B channels\n        channels = img.bandsplit()\n        r, g, b = channels[0], channels[1], channels[2]\n\n        # Apply red overlay (50% blend):\n        # Where mask is 1: blend 50% original with 50% red (255)\n        # Where mask is 0: keep original\n        r = r * (1 - mask_normalized * 0.5) + 127.5 * mask_normalized\n        g = g * (1 - mask_normalized * 0.5)\n        b = b * (1 - mask_normalized * 0.5)\n\n        # Recombine channels\n        result = r.bandjoin([g, b])\n\n        # CRITICAL: Use temp file instead of write_to_buffer()\n        # write_to_buffer() leaks C memory that isn't returned to OS\n        # See: https://github.com/libvips/pyvips/issues/234\n        try:\n            with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:\n                temp_path = tmp.name\n\n            # Write to file (doesn't leak like write_to_buffer)\n            result.write_to_file(temp_path, Q=85)\n\n            # Read bytes and clean up\n            with open(temp_path, 'rb') as f:\n                image_bytes = f.read()\n\n            os.unlink(temp_path)\n            return image_bytes\n\n        except Exception as e:\n            logger.error(f\"Failed to write image via temp file: {e}\")\n            # Fallback to write_to_buffer if temp file fails\n            return result.write_to_buffer('.jpg', Q=85)\n\n    def close(self, img: pyvips.Image) -> None:\n        \"\"\"\n        LibVIPS uses automatic reference counting.\n\n        No explicit cleanup needed - memory freed when references drop to zero.\n        \"\"\"\n        pass\n\n    def find_template(\n        self,\n        img: pyvips.Image,\n        template_img: pyvips.Image,\n        original_bbox: Tuple[int, int, int, int],\n        search_tolerance: float = 0.2\n    ) -> Tuple[int, int, int, int]:\n        \"\"\"\n        Find template in image using OpenCV template matching.\n\n        Note: This temporarily converts to numpy for OpenCV operations since\n        libvips doesn't have template matching built-in.\n        \"\"\"\n        import cv2\n        import numpy as np\n\n        try:\n            left, top, right, bottom = original_bbox\n            width = right - left\n            height = bottom - top\n\n            # Calculate search region\n            margin_x = int(width * search_tolerance)\n            margin_y = int(height * search_tolerance)\n\n            search_left = max(0, left - margin_x)\n            search_top = max(0, top - margin_y)\n            search_right = min(img.width, right + margin_x)\n            search_bottom = min(img.height, bottom + margin_y)\n\n            # Crop search region\n            search_region = self.crop(img, search_left, search_top, search_right, search_bottom)\n\n            # Convert to numpy arrays for OpenCV\n            search_array = np.ndarray(\n                buffer=search_region.write_to_memory(),\n                dtype=np.uint8,\n                shape=[search_region.height, search_region.width, search_region.bands]\n            )\n            template_array = np.ndarray(\n                buffer=template_img.write_to_memory(),\n                dtype=np.uint8,\n                shape=[template_img.height, template_img.width, template_img.bands]\n            )\n\n            # Convert to grayscale\n            if len(search_array.shape) == 3:\n                search_gray = cv2.cvtColor(search_array, cv2.COLOR_RGB2GRAY)\n            else:\n                search_gray = search_array\n\n            if len(template_array.shape) == 3:\n                template_gray = cv2.cvtColor(template_array, cv2.COLOR_RGB2GRAY)\n            else:\n                template_gray = template_array\n\n            logger.debug(f\"Searching for template in region: ({search_left}, {search_top}) to ({search_right}, {search_bottom})\")\n\n            # Perform template matching\n            result = cv2.matchTemplate(search_gray, template_gray, cv2.TM_CCOEFF_NORMED)\n            min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)\n\n            logger.debug(f\"Template matching confidence: {max_val:.2%}\")\n\n            # Check if match is good enough (80% confidence threshold)\n            if max_val >= 0.8:\n                # Calculate new bounding box in original image coordinates\n                match_x = search_left + max_loc[0]\n                match_y = search_top + max_loc[1]\n\n                new_bbox = (match_x, match_y, match_x + width, match_y + height)\n\n                # Calculate movement distance\n                move_x = abs(match_x - left)\n                move_y = abs(match_y - top)\n\n                logger.info(f\"Template found at ({match_x}, {match_y}), \"\n                           f\"moved {move_x}px horizontally, {move_y}px vertically, \"\n                           f\"confidence: {max_val:.2%}\")\n\n                return new_bbox\n            else:\n                logger.warning(f\"Template match confidence too low: {max_val:.2%} (need 80%)\")\n                return None\n\n        except Exception as e:\n            logger.error(f\"Template matching error: {e}\")\n            return None\n\n    def save_template(\n        self,\n        img: pyvips.Image,\n        bbox: Tuple[int, int, int, int],\n        output_path: str\n    ) -> bool:\n        \"\"\"\n        Save a cropped region as a template file.\n        \"\"\"\n        import os\n\n        try:\n            left, top, right, bottom = bbox\n            width = right - left\n            height = bottom - top\n\n            # Ensure output directory exists\n            os.makedirs(os.path.dirname(output_path), exist_ok=True)\n\n            # Crop template region\n            template = self.crop(img, left, top, right, bottom)\n\n            # Save as PNG\n            template.write_to_file(output_path, compression=6)\n\n            logger.info(f\"Saved template: {output_path} ({width}x{height}px)\")\n            return True\n\n        except Exception as e:\n            logger.error(f\"Failed to save template: {e}\")\n            return False\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/preview.py",
    "content": "\"\"\"\nPreview rendering for SSIM screenshot processor.\n\nRenders images properly in the browser instead of showing raw bytes.\n\"\"\"\n\nfrom flask_babel import gettext\nfrom loguru import logger\n\n\ndef get_asset(asset_name, watch, datastore, request):\n    \"\"\"\n    Get processor-specific binary assets for preview streaming.\n\n    This function supports serving images as separate HTTP responses instead\n    of embedding them as base64 in the HTML template, solving memory issues\n    with large screenshots.\n\n    Supported assets:\n    - 'screenshot': The screenshot for the specified version\n\n    Args:\n        asset_name: Name of the asset to retrieve ('screenshot')\n        watch: Watch object\n        datastore: Datastore object\n        request: Flask request (for version query param)\n\n    Returns:\n        tuple: (binary_data, content_type, cache_control_header) or None if not found\n    \"\"\"\n    if asset_name != 'screenshot':\n        return None\n\n    versions = list(watch.history.keys())\n    if len(versions) == 0:\n        return None\n\n    # Get the version from query string (default: latest)\n    preferred_version = request.args.get('version')\n    timestamp = versions[-1]\n    if preferred_version and preferred_version in versions:\n        timestamp = preferred_version\n\n    try:\n        screenshot_bytes = watch.get_history_snapshot(timestamp=timestamp)\n\n        # Verify we got bytes (should always be bytes for image files)\n        if not isinstance(screenshot_bytes, bytes):\n            logger.error(f\"Expected bytes but got {type(screenshot_bytes)} for screenshot at {timestamp}\")\n            return None\n\n        # Detect image format using puremagic (same as Watch.py)\n        try:\n            import puremagic\n            detections = puremagic.magic_string(screenshot_bytes[:2048])\n            if detections:\n                mime_type = detections[0].mime_type\n                logger.trace(f\"Detected MIME type: {mime_type}\")\n            else:\n                mime_type = 'image/png'  # Default fallback\n        except Exception as e:\n            logger.warning(f\"puremagic detection failed: {e}, using 'image/png' fallback\")\n            mime_type = 'image/png'\n\n        return (screenshot_bytes, mime_type, 'public, max-age=10')\n\n    except Exception as e:\n        logger.error(f\"Failed to load screenshot for preview asset: {e}\")\n        return None\n\n\ndef render(watch, datastore, request, url_for, render_template, flash, redirect):\n    \"\"\"\n    Render the preview page for screenshot watches.\n\n    Args:\n        watch: Watch object\n        datastore: Datastore object\n        request: Flask request\n        url_for: Flask url_for function\n        render_template: Flask render_template function\n        flash: Flask flash function\n        redirect: Flask redirect function\n\n    Returns:\n        Rendered template or redirect\n    \"\"\"\n    versions = list(watch.history.keys())\n\n    if len(versions) == 0:\n        flash(gettext(\"Preview unavailable - No snapshots captured yet\"), \"error\")\n        return redirect(url_for('watchlist.index'))\n\n    # Get the version to display (default: latest)\n    preferred_version = request.args.get('version')\n    timestamp = versions[-1]\n    if preferred_version and preferred_version in versions:\n        timestamp = preferred_version\n\n    # Render custom template for image preview\n    # Screenshot is now served via separate /processor-asset/ endpoint instead of base64\n    # This significantly reduces memory usage by not embedding large images in HTML\n    return render_template(\n        'image_ssim_diff/preview.html',\n        watch=watch,\n        uuid=watch.get('uuid'),\n        versions=versions,\n        timestamp=timestamp,\n        current_diff_url=watch['url']\n    )\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/processor.py",
    "content": "\"\"\"\nCore fast screenshot comparison processor.\n\nUses OpenCV with subprocess isolation for high-performance, low-memory\nimage processing. All operations run in isolated subprocesses for complete\nmemory cleanup and stability.\n\"\"\"\n\nimport hashlib\nimport time\nfrom loguru import logger\nfrom changedetectionio.processors.exceptions import ProcessorException\nfrom . import SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT, PROCESSOR_CONFIG_NAME, OPENCV_BLUR_SIGMA\nfrom ..base import difference_detection_processor, SCREENSHOT_FORMAT_PNG\n\n# All image operations now use OpenCV via isolated_opencv subprocess handler\n# Template matching temporarily disabled pending OpenCV implementation\n\n# Translation marker for extraction\ndef _(x): return x\nname = _('Visual / Image screenshot change detection')\ndescription = _('Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM')\ndel _\nprocessor_weight = 2\nlist_badge_text = \"Visual\"\n\nclass perform_site_check(difference_detection_processor):\n    \"\"\"Fast screenshot comparison processor using OpenCV.\"\"\"\n\n    # Override to use PNG format for better image comparison (JPEG compression creates noise)\n    screenshot_format = SCREENSHOT_FORMAT_PNG\n\n    def run_changedetection(self, watch, force_reprocess=False):\n        \"\"\"\n        Perform screenshot comparison using OpenCV subprocess handler.\n\n        Returns:\n            tuple: (changed_detected, update_obj, screenshot_bytes)\n        \"\"\"\n        now = time.time()\n        # Get the current screenshot\n        if not self.fetcher.screenshot:\n            raise ProcessorException(\n                message=\"No screenshot available. Ensure the watch is configured to use a real browser.\",\n                url=watch.get('url')\n            )\n        self.screenshot = self.fetcher.screenshot\n        self.xpath_data = self.fetcher.xpath_data\n\n        # Quick MD5 check - skip expensive comparison if images are identical\n        from changedetectionio.content_fetchers.exceptions import checksumFromPreviousCheckWasTheSame\n        current_md5 = hashlib.md5(self.screenshot).hexdigest()\n        previous_md5 = watch.get('previous_md5')\n        if previous_md5 and current_md5 == previous_md5:\n            logger.debug(f\"UUID: {watch.get('uuid')} - Screenshot MD5 unchanged ({current_md5}), skipping comparison\")\n            raise checksumFromPreviousCheckWasTheSame()\n        else:\n            logger.debug(f\"UUID: {watch.get('uuid')} - Screenshot MD5 changed\")\n\n\n\n        # Check if bounding box is set (for drawn area mode)\n        # Read from processor-specific config JSON file (named after processor)\n        crop_region = None\n\n        processor_config = self.get_extra_watch_config(PROCESSOR_CONFIG_NAME)\n        bounding_box = processor_config.get('bounding_box') if processor_config else None\n\n\n        # Get pixel difference threshold sensitivity (per-watch > global)\n        # This controls how different a pixel must be (0-255 scale) to count as \"changed\"\n        pixel_difference_threshold_sensitivity = processor_config.get('pixel_difference_threshold_sensitivity')\n        if not pixel_difference_threshold_sensitivity:\n            pixel_difference_threshold_sensitivity = self.datastore.data['settings']['application'].get('pixel_difference_threshold_sensitivity', SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT)\n        try:\n            pixel_difference_threshold_sensitivity = int(pixel_difference_threshold_sensitivity)\n        except (ValueError, TypeError):\n            logger.warning(f\"Invalid pixel_difference_threshold_sensitivity value '{pixel_difference_threshold_sensitivity}', using default\")\n            pixel_difference_threshold_sensitivity = SCREENSHOT_COMPARISON_THRESHOLD_OPTIONS_DEFAULT\n\n\n        # Get minimum change percentage (per-watch > global > env var default)\n        # This controls what percentage of pixels must change to trigger a detection\n        min_change_percentage = processor_config.get('min_change_percentage')\n        if not min_change_percentage:\n            min_change_percentage = self.datastore.data['settings']['application'].get('min_change_percentage', 1)\n        try:\n            min_change_percentage = int(min_change_percentage)\n        except (ValueError, TypeError):\n            logger.warning(f\"Invalid min_change_percentage value '{min_change_percentage}', using default 0.1\")\n            min_change_percentage = 1\n\n        # Template matching for tracking content movement\n        template_matching_enabled = processor_config.get('auto_track_region', False) #@@todo disabled for now\n\n        if bounding_box:\n            try:\n                # Parse bounding box: \"x,y,width,height\"\n                parts = [int(p.strip()) for p in bounding_box.split(',')]\n                if len(parts) == 4:\n                    x, y, width, height = parts\n                    # Crop uses (left, top, right, bottom)\n                    crop_region = (max(0, x), max(0, y), x + width, y + height)\n                    logger.info(f\"UUID: {watch.get('uuid')} - Bounding box enabled: cropping to region {crop_region} (x={x}, y={y}, w={width}, h={height})\")\n                else:\n                    logger.warning(f\"UUID: {watch.get('uuid')} - Invalid bounding box format: {bounding_box} (expected 4 values)\")\n            except Exception as e:\n                logger.warning(f\"UUID: {watch.get('uuid')} - Failed to parse bounding box '{bounding_box}': {e}\")\n\n        # If no bounding box, check if visual selector (include_filters) is set for region-based comparison\n        if not crop_region:\n            include_filters = watch.get('include_filters', [])\n\n            if include_filters and len(include_filters) > 0:\n                # Get the first filter to use for cropping\n                first_filter = include_filters[0].strip()\n\n                if first_filter and self.xpath_data:\n                    try:\n                        import json\n                        # xpath_data is JSON string from browser\n                        xpath_data_obj = json.loads(self.xpath_data) if isinstance(self.xpath_data, str) else self.xpath_data\n\n                        # Find the bounding box for the first filter\n                        for element in xpath_data_obj.get('size_pos', []):\n                            # Match the filter with the element's xpath\n                            if element.get('xpath') == first_filter and element.get('highlight_as_custom_filter'):\n                                # Found the element - extract crop coordinates\n                                left = element.get('left', 0)\n                                top = element.get('top', 0)\n                                width = element.get('width', 0)\n                                height = element.get('height', 0)\n\n                                # Crop uses (left, top, right, bottom)\n                                crop_region = (max(0, left), max(0, top), left + width, top + height)\n\n                                logger.info(f\"UUID: {watch.get('uuid')} - Visual selector enabled: cropping to region {crop_region} for filter: {first_filter}\")\n                                break\n\n                    except Exception as e:\n                        logger.warning(f\"UUID: {watch.get('uuid')} - Failed to parse xpath_data for visual selector: {e}\")\n\n        # Store original crop region for template matching\n        original_crop_region = crop_region\n\n        # Check if this is the first check (no previous history)\n        history_keys = list(watch.history.keys())\n        if len(history_keys) == 0:\n            # First check - save baseline, no comparison\n            logger.info(f\"UUID: {watch.get('uuid')} - First check for watch {watch.get('uuid')} - saving baseline screenshot\")\n\n            # LibVIPS uses automatic reference counting - no explicit cleanup needed\n            update_obj = {\n                'previous_md5': hashlib.md5(self.screenshot).hexdigest(),\n                'last_error': False\n            }\n            logger.trace(f\"Processed in {time.time() - now:.3f}s\")\n            return False, update_obj, self.screenshot\n\n        # Get previous screenshot bytes from history\n        previous_timestamp = history_keys[-1]\n        previous_screenshot_bytes = watch.get_history_snapshot(timestamp=previous_timestamp)\n\n        # Screenshots are stored as PNG, so this should be bytes\n        if isinstance(previous_screenshot_bytes, str):\n            # If it's a string (shouldn't be for screenshots, but handle it)\n            previous_screenshot_bytes = previous_screenshot_bytes.encode('utf-8')\n\n        # Template matching is temporarily disabled pending OpenCV implementation\n        # crop_region calculated above will be used as-is\n\n        # Perform comparison in isolated subprocess to prevent memory leaks\n        try:\n            from .image_handler import isolated_opencv as process_screenshot_handler\n\n# stuff in watch doesnt need to be there\n            logger.debug(f\"UUID: {watch.get('uuid')} - Starting isolated subprocess comparison (crop_region={crop_region})\")\n\n            # Compare using isolated subprocess with OpenCV (async-safe to avoid blocking event loop)\n            # Pass raw bytes and crop region - subprocess handles all image operations\n            import asyncio\n            import threading\n\n            # Async-safe wrapper: runs coroutine in new thread with its own event loop\n            # This prevents blocking the async update worker's event loop\n            def run_async_in_thread():\n                return asyncio.run(\n                    process_screenshot_handler.compare_images_isolated(\n                        img_bytes_from=previous_screenshot_bytes,\n                        img_bytes_to=self.screenshot,\n                        pixel_difference_threshold=pixel_difference_threshold_sensitivity,\n                        blur_sigma=OPENCV_BLUR_SIGMA,\n                        crop_region=crop_region  # Pass crop region for isolated cropping\n                    )\n                )\n\n            # Run in thread to avoid blocking event loop when called from async update worker\n            result_container = [None]\n            exception_container = [None]\n\n            def thread_target():\n                try:\n                    result_container[0] = run_async_in_thread()\n                except Exception as e:\n                    exception_container[0] = e\n\n            thread = threading.Thread(target=thread_target, daemon=True, name=\"ImageDiff-Processor\")\n            thread.start()\n            thread.join(timeout=60)\n\n            if exception_container[0]:\n                raise exception_container[0]\n\n            # Subprocess returns only the change score - we decide if it's a \"change\"\n            change_score = result_container[0]\n            if change_score is None:\n                raise RuntimeError(\"Image comparison subprocess returned no result\")\n\n            changed_detected = change_score > min_change_percentage\n            logger.info(f\"UUID: {watch.get('uuid')} -  {process_screenshot_handler.IMPLEMENTATION_NAME}: {change_score:.2f}% pixels changed, pixel_diff_threshold_sensitivity: {pixel_difference_threshold_sensitivity:.0f} score={change_score:.2f}%, min_change_threshold={min_change_percentage}%\")\n\n        except Exception as e:\n            logger.error(f\"UUID: {watch.get('uuid')} - Failed to compare screenshots: {e}\")\n            logger.trace(f\"UUID: {watch.get('uuid')} - Processed in {time.time() - now:.3f}s\")\n\n            raise ProcessorException(\n                message=f\"UUID: {watch.get('uuid')} - Screenshot comparison failed: {e}\",\n                url=watch.get('url')\n            )\n\n        # Return results\n        update_obj = {\n            'previous_md5': hashlib.md5(self.screenshot).hexdigest(),\n            'last_error': False\n        }\n\n        if changed_detected:\n            logger.info(f\"UUID: {watch.get('uuid')} - Change detected using OpenCV! Score: {change_score:.2f}\")\n        else:\n            logger.debug(f\"UUID: {watch.get('uuid')} - No significant change using OpenCV. Score: {change_score:.2f}\")\n        logger.trace(f\"UUID: {watch.get('uuid')} - Processed in {time.time() - now:.3f}s\")\n\n        return changed_detected, update_obj, self.screenshot\n\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/templates/image_ssim_diff/diff.html",
    "content": "{% extends 'base.html' %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}\n\n{% block content %}\n<link rel=\"stylesheet\" href=\"{{url_for('static_content', group='styles', filename='diff-image.css')}}?v={{ get_css_version() }}\">\n<script src=\"{{url_for('static_content', group='js', filename='diff-overview.js')}}\" defer></script>\n\n<div id=\"settings\">\n    <form class=\"pure-form \" action=\"{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid) }}\" method=\"GET\" id=\"diff-form\">\n        <fieldset class=\"diff-fieldset\">\n            {% if versions|length >= 1 %}\n                <span style=\"white-space: nowrap;\">\n                <label id=\"change-from\" for=\"diff-from-version\" class=\"from-to-label\">From</label>\n                <select id=\"diff-from-version\" name=\"from_version\" class=\"needs-localtime\">\n                    {%- for version in versions|reverse -%}\n                        <option value=\"{{ version }}\" {% if version== from_version %} selected=\"\" {% endif %}>\n                            {{ version }}\n                        </option>\n                    {%- endfor -%}\n                </select>\n                </span>\n                <span style=\"white-space: nowrap;\">\n                <label id=\"change-to\" for=\"diff-to-version\" class=\"from-to-label\">To</label>\n                <select id=\"diff-to-version\" name=\"to_version\" class=\"needs-localtime\">\n                    {%- for version in versions|reverse -%}\n                        <option value=\"{{ version }}\" {% if version== to_version %} selected=\"\" {% endif %}>\n                            {{ version }}\n                        </option>\n                    {%- endfor -%}\n                </select>\n                </span>\n            {% endif %}\n        </fieldset>\n        <fieldset id=\"diff-style\">\n            <span>\n                <strong>Change Detection:</strong> {{ \"%.2f\"|format(change_percentage) }}% of pixels changed\n                {% if change_percentage > 0.1 %}\n                    <span class=\"change-detected\">⚠ Change Detected</span>\n                {% else %}\n                    <span class=\"no-change\">✓ No Significant Change</span>\n                {% endif %}\n            </span>\n        </fieldset>\n        {%- if versions|length >= 2 -%}\n            <div id=\"keyboard-nav\">\n                <strong>Keyboard: </strong>\n                <a href=\"\" class=\"pure-button pure-button-primary\" id=\"btn-previous\"> &larr; Previous</a>\n                &nbsp; <a class=\"pure-button pure-button-primary\" id=\"btn-next\" href=\"\"> &rarr; Next</a>\n            </div>\n        {%- endif -%}\n    </form>\n</div>\n\n<div id=\"screenshot-comparison\">\n    <!-- Two-panel layout: Interactive slider + Static diff -->\n    <div class=\"comparison-grid\">\n        <!-- Panel 1: Interactive Comparison Slider (Previous ↔ Current) -->\n        <div class=\"screenshot-panel\">\n            <h3>Interactive Comparison</h3>\n            <div class=\"comparison-description\">\n                Drag slider to compare Previous ({{ from_version|format_timestamp_timeago }})\n                vs Current ({{ to_version|format_timestamp_timeago }})\n            </div>\n            <div style=\"text-align: center; margin-bottom: 0.5em; display: flex; justify-content: center; gap: 1em;\">\n                <a href=\"#\" onclick=\"downloadImage('img-before', '{{ from_version }}'); return false;\" class=\"download-link\" title=\"Download previous snapshot\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"currentColor\" style=\"display: inline-block;\">\n                        <path d=\"M8 12L3 7h3V1h4v6h3z\"/>\n                        <path d=\"M1 14h14v2H1z\"/>\n                    </svg>\n                    Previous\n                </a>\n                <a href=\"#\" onclick=\"downloadImage('img-after', '{{ to_version }}'); return false;\" class=\"download-link\" title=\"Download current snapshot\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"currentColor\" style=\"display: inline-block;\">\n                        <path d=\"M8 12L3 7h3V1h4v6h3z\"/>\n                        <path d=\"M1 14h14v2H1z\"/>\n                    </svg>\n                    Current\n                </a>\n            </div>\n\n            <div class=\"image-comparison\" id=\"comparison-container\">\n                <!-- Before image wrapper (Previous snapshot) -->\n                <div class=\"comparison-image-wrapper\">\n                    <img id=\"img-before\" src=\"{{ url_for('ui.ui_diff.processor_asset', uuid=uuid, asset_name='before', from_version=from_version, to_version=to_version) }}\" alt=\"Previous screenshot\">\n                </div>\n\n                <!-- After image wrapper (Current snapshot) -->\n                <div class=\"comparison-image-wrapper comparison-after\">\n                    <img id=\"img-after\" src=\"{{ url_for('ui.ui_diff.processor_asset', uuid=uuid, asset_name='after', from_version=from_version, to_version=to_version) }}\" alt=\"Current screenshot\">\n                </div>\n\n                <!-- Labels -->\n                <div class=\"comparison-labels\">\n                    <span class=\"comparison-label\">Previous</span>\n                    <span class=\"comparison-label\">Current</span>\n                </div>\n\n                <!-- Draggable slider -->\n                <div class=\"comparison-slider\" id=\"comparison-slider\">\n                    <div class=\"comparison-handle\"></div>\n                </div>\n            </div>\n        </div>\n\n        <!-- Panel 2: Difference Visualization (Static) -->\n        <div class=\"screenshot-panel diff\">\n            <h3>Difference Visualization</h3>\n            <div class=\"diff-section-header\">\n                <span>Red = Changed Pixels</span>\n            </div>\n            <div style=\"text-align: center; margin-bottom: 0.5em;\">\n                <a href=\"#\" onclick=\"downloadImage('diff-image', '{{ to_version }}_diff'); return false;\" class=\"download-link\" title=\"Download difference image\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"currentColor\" style=\"display: inline-block;\">\n                        <path d=\"M8 12L3 7h3V1h4v6h3z\"/>\n                        <path d=\"M1 14h14v2H1z\"/>\n                    </svg>\n                    Download\n                </a>\n            </div>\n            <img id=\"diff-image\" src=\"{{ url_for('ui.ui_diff.processor_asset', uuid=uuid, asset_name='rendered_diff', from_version=from_version, to_version=to_version) }}\" alt=\"Difference visualization with red highlights\">\n        </div>\n    </div>\n\n    {% if comparison_data and comparison_data.get('history') and comparison_data.history|length > 1 %}\n    <div class=\"comparison-history-section\">\n        <h3>Comparison History</h3>\n        <p>Recent comparison results (last {{ comparison_data.history|length }} checks)</p>\n        <div style=\"overflow-x: auto;\">\n            <table class=\"pure-table pure-table-striped\" style=\"width: 100%;\">\n                <thead>\n                    <tr>\n                        <th>Timestamp</th>\n                        <th>Change %</th>\n                        <th>Method</th>\n                        <th>Changed?</th>\n                    </tr>\n                </thead>\n                <tbody>\n                    {% for entry in comparison_data.history|reverse %}\n                    <tr>\n                        <td>{{ entry.timestamp|format_timestamp_timeago }}</td>\n                        <td>{{ \"%.2f\"|format(entry.change_percentage) }}%</td>\n                        <td>{{ entry.method }}</td>\n                        <td>\n                            {% if entry.changed %}\n                                <span class=\"history-changed-yes\">Yes</span>\n                            {% else %}\n                                <span class=\"history-changed-no\">No</span>\n                            {% endif %}\n                        </td>\n                    </tr>\n                    {% endfor %}\n                </tbody>\n            </table>\n        </div>\n    </div>\n    {% endif %}\n</div>\n\n<script>\nfunction downloadImage(imageId, filename) {\n    // Get the image element\n    const img = document.getElementById(imageId);\n    const base64Data = img.src;\n\n    // Convert base64 to blob\n    const byteString = atob(base64Data.split(',')[1]);\n    const mimeString = base64Data.split(',')[0].split(':')[1].split(';')[0];\n\n    const ab = new ArrayBuffer(byteString.length);\n    const ia = new Uint8Array(ab);\n    for (let i = 0; i < byteString.length; i++) {\n        ia[i] = byteString.charCodeAt(i);\n    }\n\n    const blob = new Blob([ab], { type: mimeString });\n\n    // Determine file extension from MIME type\n    const extension = mimeString.includes('jpeg') ? '.jpeg' : '.png';\n\n    // Create download link\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename + extension;\n    document.body.appendChild(a);\n    a.click();\n\n    // Cleanup\n    setTimeout(() => {\n        document.body.removeChild(a);\n        URL.revokeObjectURL(url);\n    }, 100);\n}\n\n/**\n * Synchronize comparison slider width with diff image width\n * This ensures both panels display images at the same max-width\n */\nfunction syncComparisonWidth() {\n    const diffImage = document.getElementById('diff-image');\n    const comparisonContainer = document.getElementById('comparison-container');\n\n    if (!diffImage || !comparisonContainer) return;\n\n    // Wait for diff image to load to get its actual rendered width\n    if (diffImage.complete) {\n        applyWidth();\n    } else {\n        diffImage.addEventListener('load', applyWidth);\n    }\n\n    function applyWidth() {\n        const diffImageWidth = diffImage.offsetWidth;\n        if (diffImageWidth > 0) {\n            comparisonContainer.style.maxWidth = diffImageWidth + 'px';\n            comparisonContainer.style.margin = '0 auto';\n        }\n    }\n}\n\n// Run on page load\nif (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', syncComparisonWidth);\n} else {\n    syncComparisonWidth();\n}\n\n// Re-sync on window resize\nwindow.addEventListener('resize', syncComparisonWidth);\n</script>\n\n<script src=\"{{ url_for('static_content', group='js', filename='comparison-slider.js') }}\" defer></script>\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/templates/image_ssim_diff/preview.html",
    "content": "{% extends 'base.html' %}\n\n{% block content %}\n<script src=\"{{ url_for('static_content', group='js', filename='preview.js') }}\" defer></script>\n    {% if versions|length >= 2 %}\n        <div id=\"diff-form\" style=\"text-align: center;\">\n            <form class=\"pure-form \" action=\"\" method=\"GET\">\n                <fieldset>\n                    <label for=\"preview-version\">Select timestamp</label> <select id=\"preview-version\"\n                                                                                 name=\"version\"\n                                                                                 class=\"needs-localtime\">\n                    {% for version in versions|reverse %}\n                        <option value=\"{{ version }}\" {% if version == timestamp %} selected=\"\" {% endif %}>\n                            {{ version }}\n                        </option>\n                    {% endfor %}\n                </select>\n                    <button type=\"submit\" class=\"pure-button pure-button-primary\">Go</button>\n\n                </fieldset>\n            </form>\n                <br>\n                <strong>Keyboard: </strong><a href=\"\" class=\"pure-button pure-button-primary\" id=\"btn-previous\">\n                &larr; Previous</a> &nbsp; <a class=\"pure-button pure-button-primary\" id=\"btn-next\" href=\"\">\n                &rarr; Next</a>\n        </div>\n    {% endif %}\n\n    <div id=\"screenshot-container\" style=\"text-align: center; border: 1px solid #ddd; padding: 2em; background: #fafafa; border-radius: 4px;\">\n        <h3 style=\"margin-top: 0;\">Screenshot from {{ timestamp|format_timestamp_timeago }}</h3>\n        <img src=\"{{ url_for('ui.ui_preview.processor_asset', uuid=uuid, asset_name='screenshot', version=timestamp) }}\"\n             alt=\"Screenshot preview\"\n             style=\"max-width: 100%; height: auto; border: 1px solid #ccc; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 2px;\">\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/processors/image_ssim_diff/util.py",
    "content": "\"\"\"\nDEPRECATED: All multiprocessing functions have been removed.\n\nThe image_ssim_diff processor now uses LibVIPS via ImageDiffHandler abstraction,\nwhich provides superior performance and memory efficiency through streaming\narchitecture and automatic threading.\n\nAll image operations are now handled by:\n- imagehandler.py: Abstract base class defining the interface\n- libvips_handler.py: LibVIPS implementation with streaming and threading\n\nHistorical note: This file previously contained multiprocessing workers for:\n- Template matching (find_region_with_template_matching_isolated)\n- Template regeneration (regenerate_template_isolated)\n- Image cropping (crop_image_isolated, crop_pil_image_isolated)\n\nThese have been replaced by handler methods which are:\n- Faster (no subprocess overhead)\n- More memory efficient (LibVIPS streaming)\n- Cleaner (no multiprocessing deadlocks)\n- Better tested (no logger/forking issues)\n\"\"\"\n"
  },
  {
    "path": "changedetectionio/processors/magic.py",
    "content": "\"\"\"\nContent Type Detection and Stream Classification\n\nThis module provides intelligent content-type detection for changedetection.io.\nIt addresses the common problem where HTTP Content-Type headers are missing, incorrect,\nor too generic, which would otherwise cause the wrong processor to be used.\n\nThe guess_stream_type class combines:\n1. HTTP Content-Type headers (when available and reliable)\n2. Python-magic library for MIME detection (analyzing actual file content)\n3. Content-based pattern matching for text formats (HTML tags, XML declarations, etc.)\n\nThis multi-layered approach ensures accurate detection of RSS feeds, JSON, HTML, PDF,\nplain text, CSV, YAML, and XML formats - even when servers provide misleading headers.\n\nUsed by: processors/text_json_diff/processor.py and other content processors\n\"\"\"\n\n# When to apply the 'cdata to real HTML' hack\nRSS_XML_CONTENT_TYPES = [\n    \"application/rss+xml\",\n    \"application/rdf+xml\",\n    \"application/atom+xml\",\n    \"text/rss+xml\",  # rare, non-standard\n    \"application/x-rss+xml\",  # legacy (older feed software)\n    \"application/x-atom+xml\",  # legacy (older Atom)\n]\n\n# JSON Content-types\nJSON_CONTENT_TYPES = [\n    \"application/activity+json\",\n    \"application/feed+json\",\n    \"application/json\",\n    \"application/ld+json\",\n    \"application/vnd.api+json\",\n]\n\n\n# Generic XML Content-types (non-RSS/Atom)\nXML_CONTENT_TYPES = [\n    \"text/xml\",\n    \"application/xml\",\n]\n\nHTML_PATTERNS = ['<!doctype html', '<html', '<head', '<body', '<script', '<iframe', '<div']\n\nfrom loguru import logger\n\nclass guess_stream_type():\n    is_pdf = False\n    is_json = False\n    is_html = False\n    is_plaintext = False\n    is_rss = False\n    is_csv = False\n    is_xml = False  # Generic XML, not RSS/Atom\n    is_yaml = False\n\n    def __init__(self, http_content_header, content):\n        import re\n        magic_content_header = http_content_header\n        test_content = content[:200].lower().strip()\n\n        # Remove whitespace between < and tag name for robust detection (handles '< html', '<\\nhtml', etc.)\n        test_content_normalized = re.sub(r'<\\s+', '<', test_content)\n\n        # Use puremagic for lightweight MIME detection (saves ~14MB vs python-magic)\n        magic_result = None\n        try:\n            import puremagic\n\n            # puremagic needs bytes, so encode if we have a string\n            content_bytes = content[:200].encode('utf-8') if isinstance(content, str) else content[:200]\n\n            # puremagic returns a list of PureMagic objects with confidence scores\n            detections = puremagic.magic_string(content_bytes)\n            if detections:\n                # Get the highest confidence detection\n                mime = detections[0].mime_type\n                logger.debug(f\"Guessing mime type, original content_type '{http_content_header}', mime type detected '{mime}'\")\n                if mime and \"/\" in mime:\n                    magic_result = mime\n                    # Ignore generic/fallback mime types\n                    if mime in ['application/octet-stream', 'application/x-empty', 'binary']:\n                        logger.debug(f\"Ignoring generic mime type '{mime}' from puremagic library\")\n                    # Trust puremagic for non-text types immediately\n                    elif mime not in ['text/html', 'text/plain']:\n                        magic_content_header = mime\n\n        except Exception as e:\n            logger.warning(f\"Error getting a more precise mime type from 'puremagic' library ({str(e)}), using content-based detection\")\n\n        # Content-based detection (most reliable for text formats)\n        # Check for HTML patterns first - if found, override magic's text/plain\n        has_html_patterns = any(p in test_content_normalized for p in HTML_PATTERNS)\n\n        # Always trust headers first\n        if 'text/plain' in http_content_header:\n            self.is_plaintext = True\n        if any(s in http_content_header for s in RSS_XML_CONTENT_TYPES):\n            self.is_rss = True\n        elif any(s in http_content_header for s in JSON_CONTENT_TYPES):\n            # JSONP detection: server claims application/json but content is actually JSONP (e.g. cb({...}))\n            # A JSONP response starts with an identifier followed by '(' - not valid JSON\n            if re.match(r'^\\w[\\w.]*\\s*\\(', test_content):\n                logger.warning(f\"Content-Type header claims JSON but content looks like JSONP (starts with identifier+parenthesis) - treating as plaintext\")\n                self.is_plaintext = True\n            else:\n                self.is_json = True\n        elif 'pdf' in magic_content_header:\n            self.is_pdf = True\n        # magic will call a rss document 'xml'\n        # Rarely do endpoints give the right header, usually just text/xml, so we check also for <rss\n        # This also triggers the automatic CDATA text parser so the RSS goes back a nice content list\n        elif '<rss' in test_content_normalized or '<feed' in test_content_normalized or any(s in magic_content_header for s in RSS_XML_CONTENT_TYPES) or '<rdf:' in test_content_normalized:\n            self.is_rss = True\n        elif has_html_patterns or http_content_header == 'text/html':\n            self.is_html = True\n        elif any(s in magic_content_header for s in JSON_CONTENT_TYPES):\n            self.is_json = True\n        elif any(s in http_content_header for s in XML_CONTENT_TYPES):\n            # Only mark as generic XML if not already detected as RSS\n            if not self.is_rss:\n                self.is_xml = True\n        elif test_content_normalized.startswith('<?xml') or any(s in magic_content_header for s in XML_CONTENT_TYPES):\n            # Generic XML that's not RSS/Atom (RSS/Atom checked above)\n            self.is_xml = True\n        elif '%pdf-1' in test_content:\n            self.is_pdf = True\n        elif http_content_header.startswith('text/'):\n            self.is_plaintext = True\n        # Only trust magic for 'text' if no other patterns matched\n        elif 'text' in magic_content_header:\n            self.is_plaintext = True\n        # If magic says text/plain and we found no HTML patterns, trust it\n        elif magic_result == 'text/plain':\n            self.is_plaintext = True\n            logger.debug(f\"Trusting magic's text/plain result (no HTML patterns detected)\")\n\n"
  },
  {
    "path": "changedetectionio/processors/restock_diff/__init__.py",
    "content": "\nfrom babel.numbers import parse_decimal\nfrom changedetectionio.model.Watch import model as BaseWatch\nfrom typing import Union\nimport re\n\n# Processor capabilities\nsupports_visual_selector = True\nsupports_browser_steps = True\nsupports_text_filters_and_triggers = True\nsupports_text_filters_and_triggers_elements = True\nsupports_request_type = True\n\nclass Restock(dict):\n\n    def parse_currency(self, raw_value: str) -> Union[float, None]:\n        # Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer.\n        standardized_value = raw_value\n\n        if ',' in standardized_value and '.' in standardized_value:\n            # Identify the correct decimal separator\n            if standardized_value.rfind('.') > standardized_value.rfind(','):\n                standardized_value = standardized_value.replace(',', '')\n            else:\n                standardized_value = standardized_value.replace('.', '').replace(',', '.')\n        else:\n            standardized_value = standardized_value.replace(',', '.')\n\n        # Remove any non-numeric characters except for the decimal point\n        standardized_value = re.sub(r'[^\\d.-]', '', standardized_value)\n\n        if standardized_value:\n            # Convert to float\n            # @todo locale needs to be the locale of the webpage\n            return float(parse_decimal(standardized_value, locale='en'))\n\n        return None\n\n    def __init__(self, *args, **kwargs):\n        # Define default values\n        default_values = {\n            'in_stock': None,\n            'price': None,\n            'currency': None,\n            'original_price': None\n        }\n\n        # Initialize the dictionary with default values\n        super().__init__(default_values)\n\n        # Update with any provided positional arguments (dictionaries)\n        if args:\n            if len(args) == 1 and isinstance(args[0], dict):\n                self.update(args[0])\n            else:\n                raise ValueError(\"Only one positional argument of type 'dict' is allowed\")\n\n    def __setitem__(self, key, value):\n        # Custom logic to handle setting price and original_price\n        if key == 'price' or key == 'original_price':\n            if isinstance(value, str):\n                value = self.parse_currency(raw_value=value)\n\n        super().__setitem__(key, value)\n\nclass Watch(BaseWatch):\n    def __init__(self, *arg, **kw):\n        super().__init__(*arg, **kw)\n        self['restock'] = Restock(kw['default']['restock']) if kw.get('default') and kw['default'].get('restock') else Restock()\n\n\n    def clear_watch(self):\n        super().clear_watch()\n        self.update({'restock': Restock()})\n\n    def extra_notification_token_values(self):\n        values = super().extra_notification_token_values()\n        values['restock'] = self.get('restock', {})\n        return values\n\n    def extra_notification_token_placeholder_info(self):\n        values = super().extra_notification_token_placeholder_info()\n\n        values.append(('restock.price', \"Price detected\"))\n        values.append(('restock.original_price', \"Original price at first check\"))\n\n        return values\n\n"
  },
  {
    "path": "changedetectionio/processors/restock_diff/api.yaml",
    "content": "components:\n  schemas:\n    processor_config_restock_diff:\n      type: object\n      description: Configuration for the restock_diff processor (restock and price tracking)\n      properties:\n        in_stock_processing:\n          type: string\n          enum: [in_stock_only, all_changes, 'off']\n          default: in_stock_only\n          description: |\n            When to trigger on stock changes:\n            - `in_stock_only`: Only trigger on Out Of Stock -> In Stock transitions\n            - `all_changes`: Trigger on any availability change\n            - `off`: Disable stock/availability tracking\n        follow_price_changes:\n          type: boolean\n          default: true\n          description: Monitor and track price changes\n        price_change_min:\n          type: [number, 'null']\n          description: Trigger a notification when the price drops below this value\n        price_change_max:\n          type: [number, 'null']\n          description: Trigger a notification when the price rises above this value\n        price_change_threshold_percent:\n          type: [number, 'null']\n          minimum: 0\n          maximum: 100\n          description: Minimum price change percentage since the original price to trigger a notification\n\npaths:\n  /watch:\n    post:\n      x-code-samples:\n        - lang: 'curl'\n          label: 'Restock & price tracking'\n          source: |\n            curl -X POST \"http://localhost:5000/api/v1/watch\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"url\": \"https://example.com/product\",\n                \"processor\": \"restock_diff\",\n                \"processor_config_restock_diff\": {\n                  \"in_stock_processing\": \"in_stock_only\",\n                  \"follow_price_changes\": true,\n                  \"price_change_threshold_percent\": 5\n                }\n              }'\n        - lang: 'Python'\n          label: 'Restock & price tracking'\n          source: |\n            import requests\n\n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            data = {\n                'url': 'https://example.com/product',\n                'processor': 'restock_diff',\n                'processor_config_restock_diff': {\n                    'in_stock_processing': 'in_stock_only',\n                    'follow_price_changes': True,\n                    'price_change_threshold_percent': 5,\n                }\n            }\n            response = requests.post('http://localhost:5000/api/v1/watch',\n                                     headers=headers, json=data)\n            print(response.json())\n\n  /watch/{uuid}:\n    put:\n      x-code-samples:\n        - lang: 'curl'\n          label: 'Update restock config'\n          source: |\n            curl -X PUT \"http://localhost:5000/api/v1/watch/YOUR-UUID\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"processor_config_restock_diff\": {\n                  \"in_stock_processing\": \"all_changes\",\n                  \"follow_price_changes\": true,\n                  \"price_change_min\": 10.00,\n                  \"price_change_max\": 500.00\n                }\n              }'\n        - lang: 'Python'\n          label: 'Update restock config'\n          source: |\n            import requests\n\n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            uuid = 'YOUR-UUID'\n            data = {\n                'processor_config_restock_diff': {\n                    'in_stock_processing': 'all_changes',\n                    'follow_price_changes': True,\n                    'price_change_min': 10.00,\n                    'price_change_max': 500.00,\n                }\n            }\n            response = requests.put(f'http://localhost:5000/api/v1/watch/{uuid}',\n                                    headers=headers, json=data)\n            print(response.text)\n\n  /tag/{uuid}:\n    put:\n      x-code-samples:\n        - lang: 'curl'\n          label: 'Set restock config on group/tag'\n          source: |\n            curl -X PUT \"http://localhost:5000/api/v1/tag/YOUR-TAG-UUID\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"overrides_watch\": true,\n                \"processor_config_restock_diff\": {\n                  \"in_stock_processing\": \"in_stock_only\",\n                  \"follow_price_changes\": true,\n                  \"price_change_threshold_percent\": 10\n                }\n              }'\n        - lang: 'Python'\n          label: 'Set restock config on group/tag'\n          source: |\n            import requests\n\n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            tag_uuid = 'YOUR-TAG-UUID'\n            data = {\n                'overrides_watch': True,\n                'processor_config_restock_diff': {\n                    'in_stock_processing': 'in_stock_only',\n                    'follow_price_changes': True,\n                    'price_change_threshold_percent': 10,\n                }\n            }\n            response = requests.put(f'http://localhost:5000/api/v1/tag/{tag_uuid}',\n                                    headers=headers, json=data)\n            print(response.text)\n"
  },
  {
    "path": "changedetectionio/processors/restock_diff/forms.py",
    "content": "from wtforms import (\n    BooleanField,\n    validators,\n    FloatField\n)\nfrom wtforms.fields.choices import RadioField\nfrom wtforms.fields.form import FormField\nfrom wtforms.form import Form\nfrom flask_babel import lazy_gettext as _l\n\nfrom changedetectionio.forms import processor_text_json_diff_form\n\n\nclass RestockSettingsForm(Form):\n    in_stock_processing = RadioField(label=_l('Re-stock detection'), choices=[\n        ('in_stock_only', _l(\"In Stock only (Out Of Stock -> In Stock only)\")),\n        ('all_changes', _l(\"Any availability changes\")),\n        ('off', _l(\"Off, don't follow availability/restock\")),\n    ], default=\"in_stock_only\")\n\n    price_change_min = FloatField(_l('Below price to trigger notification'), [validators.Optional()],\n                                  render_kw={\"placeholder\": _l(\"No limit\"), \"size\": \"10\"})\n    price_change_max = FloatField(_l('Above price to trigger notification'), [validators.Optional()],\n                                  render_kw={\"placeholder\": _l(\"No limit\"), \"size\": \"10\"})\n    price_change_threshold_percent = FloatField(_l('Threshold in %% for price changes since the original price'), validators=[\n\n        validators.Optional(),\n        validators.NumberRange(min=0, max=100, message=_l(\"Should be between 0 and 100\")),\n    ], render_kw={\"placeholder\": \"0%\", \"size\": \"5\"})\n\n    follow_price_changes = BooleanField(_l('Follow price changes'), default=True)\n\nclass processor_settings_form(processor_text_json_diff_form):\n    processor_config_restock_diff = FormField(RestockSettingsForm)\n\n    def extra_tab_content(self):\n        return _l('Restock & Price Detection')\n\n    def extra_form_content(self):\n        output = \"\"\n\n        if getattr(self, 'watch', None) and getattr(self, 'datastore'):\n            for tag_uuid in self.watch.get('tags'):\n                tag = self.datastore.data['settings']['application']['tags'].get(tag_uuid, {})\n                if tag.get('overrides_watch'):\n                    # @todo - Quick and dirty, cant access 'url_for' here because its out of scope somehow\n                    output = f\"\"\"<p><strong>Note! A Group tag overrides the restock and price detection here.</strong></p><style>#restock-fieldset-price-group {{ opacity: 0.6; }}</style>\"\"\"\n\n        output += \"\"\"\n        {% from '_helpers.html' import render_field, render_checkbox_field, render_button %}\n        <script>\n            $(document).ready(function () {\n                toggleOpacity('#processor_config_restock_diff-follow_price_changes', '.price-change-minmax', true);\n            });\n        </script>\n\n        <fieldset id=\"restock-fieldset-price-group\">\n            <div class=\"pure-control-group\">\n                <fieldset class=\"pure-group inline-radio\">\n                    {{ render_field(form.processor_config_restock_diff.in_stock_processing) }}\n                </fieldset>\n                <fieldset class=\"pure-group\">\n                    {{ render_checkbox_field(form.processor_config_restock_diff.follow_price_changes) }}\n                    <span class=\"pure-form-message-inline\">Changes in price should trigger a notification</span>\n                </fieldset>\n                <fieldset class=\"pure-group price-change-minmax\">\n                    {{ render_field(form.processor_config_restock_diff.price_change_min, placeholder=watch.get('restock', {}).get('price')) }}\n                    <span class=\"pure-form-message-inline\">Minimum amount, Trigger a change/notification when the price drops <i>below</i> this value.</span>\n                </fieldset>\n                <fieldset class=\"pure-group price-change-minmax\">\n                    {{ render_field(form.processor_config_restock_diff.price_change_max, placeholder=watch.get('restock', {}).get('price')) }}\n                    <span class=\"pure-form-message-inline\">Maximum amount, Trigger a change/notification when the price rises <i>above</i> this value.</span>\n                </fieldset>\n                <fieldset class=\"pure-group price-change-minmax\">\n                    {{ render_field(form.processor_config_restock_diff.price_change_threshold_percent) }}\n                    <span class=\"pure-form-message-inline\">Price must change more than this % to trigger a change since the first check.</span><br>\n                    <span class=\"pure-form-message-inline\">For example, If the product is $1,000 USD originally, <strong>2%</strong> would mean it has to change more than $20 since the first check.</span><br>\n                </fieldset>\n            </div>\n        </fieldset>\n        \"\"\"\n        return output"
  },
  {
    "path": "changedetectionio/processors/restock_diff/processor.py",
    "content": "from ..base import difference_detection_processor\nfrom ..exceptions import ProcessorException\nfrom . import Restock\nfrom loguru import logger\nfrom changedetectionio.content_fetchers.exceptions import checksumFromPreviousCheckWasTheSame\n\nimport urllib3\nimport time\n\nurllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n# Translatable strings - extracted by pybabel, translated at runtime in __init__.py\n# Use a marker function so pybabel can extract these strings\ndef _(x): return x  # Translation marker for extraction only\nname = _('Re-stock & Price detection for pages with a SINGLE product')\ndescription = _('Detects if the product goes back to in-stock')\ndel _  # Remove marker function\nprocessor_weight = 1\nlist_badge_text = \"Restock\"  # _()\n\nclass UnableToExtractRestockData(Exception):\n    def __init__(self, status_code):\n        # Set this so we can use it in other parts of the app\n        self.status_code = status_code\n        return\n\nclass MoreThanOnePriceFound(Exception):\n    def __init__(self):\n        return\n\ndef _search_prop_by_value(matches, value):\n    for properties in matches:\n        for prop in properties:\n            if value in prop[0]:\n                return prop[1]  # Yield the desired value and exit the function\n\ndef _deduplicate_prices(data):\n    import re\n\n    '''\n    Some price data has multiple entries, OR it has a single entry with ['$159', '159', 159, \"$ 159\"] or just \"159\"\n    Get all the values, clean it and add it to a set then return the unique values\n    '''\n    unique_data = set()\n\n    # Return the complete 'datum' where its price was not seen before\n    for datum in data:\n\n        if isinstance(datum.value, list):\n            # Process each item in the list\n            normalized_value = set([float(re.sub(r'[^\\d.]', '', str(item))) for item in datum.value if str(item).strip()])\n            unique_data.update(normalized_value)\n        else:\n            # Process single value\n            v = float(re.sub(r'[^\\d.]', '', str(datum.value)))\n            unique_data.add(v)\n\n    return list(unique_data)\n\n\n# =============================================================================\n# MEMORY MANAGEMENT: Why We Use Multiprocessing (Linux Only)\n# =============================================================================\n#\n# The get_itemprop_availability() function uses 'extruct' to parse HTML metadata\n# (JSON-LD, microdata, OpenGraph, etc). Extruct internally uses lxml, which wraps\n# libxml2 - a C library that allocates memory at the C level.\n#\n# Memory Leak Problem:\n# --------------------\n# 1. lxml's document_fromstring() creates thousands of Python objects backed by\n#    C-level allocations (nodes, attributes, text content)\n# 2. Python's garbage collector can mark these objects as collectible, but\n#    cannot force the OS to reclaim the actual C-level memory\n# 3. malloc/free typically doesn't return memory to OS - it just marks it as\n#    \"free in the process address space\"\n# 4. With repeated parsing of large HTML (5MB+ pages), memory accumulates even\n#    after Python GC runs\n#\n# Why Multiprocessing Fixes This:\n# --------------------------------\n# When a subprocess exits, the OS forcibly reclaims ALL memory including C-level\n# allocations that Python GC couldn't release. This ensures clean memory state\n# after each extraction.\n#\n# Performance Impact:\n# -------------------\n# - Memray analysis showed 1.2M document_fromstring allocations per page\n# - Without subprocess: memory grows by ~50-500MB per parse and lingers\n# - With subprocess: ~35MB overhead but forces full cleanup after each run\n# - Trade-off: 35MB resource_tracker vs 500MB+ accumulated leak = much better at scale\n#\n# References:\n# -----------\n# - lxml memory issues: https://medium.com/devopss-hole/python-lxml-memory-leak-b8d0b1000dc7\n# - libxml2 caching behavior: https://www.mail-archive.com/lxml@python.org/msg00026.html\n# - GC limitations with C extensions: https://benbernardblog.com/tracking-down-a-freaky-python-memory-leak-part-2/\n#\n# Additional Context:\n# -------------------\n# - jsonpath_ng (used to query the parsed data) is pure Python and doesn't leak\n# - The leak is specifically from lxml's document parsing, not the JSONPath queries\n# - Linux-only because multiprocessing spawn is well-tested there; other platforms\n#   use direct call as fallback\n#\n# Alternative Solution (Future Optimization):\n# -------------------------------------------\n# This entire problem could be avoided by using regex to extract just the machine\n# data blocks (JSON-LD, microdata, OpenGraph tags) BEFORE parsing with lxml:\n#\n#   1. Use regex to extract <script type=\"application/ld+json\">...</script> blocks\n#   2. Use regex to extract <meta property=\"og:*\"> tags\n#   3. Use regex to find itemprop/itemtype attributes and their containing elements\n#   4. Parse ONLY those extracted snippets instead of the entire HTML document\n#\n# Benefits:\n#   - Avoids parsing 5MB of HTML when we only need a few KB of metadata\n#   - Eliminates the lxml memory leak entirely\n#   - Faster extraction (regex is much faster than DOM parsing)\n#   - No subprocess overhead needed\n#\n# Trade-offs:\n#   - Regex for HTML is brittle (comments, CDATA, edge cases)\n#   - Microdata extraction would be complex (need to track element boundaries)\n#   - Would need extensive testing to ensure we don't miss valid data\n#   - extruct is battle-tested; regex solution would need similar maturity\n#\n# For now, the subprocess approach is safer and leverages existing extruct code.\n# =============================================================================\n\n\ndef _extract_itemprop_availability_worker(pipe_conn):\n    \"\"\"\n    Subprocess worker for itemprop extraction (Linux memory management).\n\n    Uses spawn multiprocessing to isolate extruct/lxml memory allocations.\n    When the subprocess exits, the OS reclaims ALL memory including lxml's\n    C-level allocations that Python's GC cannot release.\n\n    Args:\n        pipe_conn: Pipe connection to receive HTML and send result\n    \"\"\"\n    import json\n    import gc\n\n    html_content = None\n    result_data = None\n\n    try:\n        # Receive HTML as raw bytes (no pickle)\n        html_bytes = pipe_conn.recv_bytes()\n        html_content = html_bytes.decode('utf-8')\n\n        # Explicitly delete html_bytes to free memory\n        del html_bytes\n        gc.collect()\n\n        # Perform extraction in subprocess (uses extruct/lxml)\n        result_data = get_itemprop_availability(html_content)\n\n        # Convert Restock object to dict for JSON serialization\n        result = {\n            'success': True,\n            'data': dict(result_data) if result_data else {}\n        }\n        pipe_conn.send_bytes(json.dumps(result).encode('utf-8'))\n\n        # Clean up before exit\n        del result_data, html_content, result\n        gc.collect()\n\n    except MoreThanOnePriceFound:\n        # Serialize the specific exception type\n        result = {\n            'success': False,\n            'exception_type': 'MoreThanOnePriceFound'\n        }\n        pipe_conn.send_bytes(json.dumps(result).encode('utf-8'))\n\n    except Exception as e:\n        # Serialize other exceptions\n        result = {\n            'success': False,\n            'exception_type': type(e).__name__,\n            'exception_message': str(e)\n        }\n        pipe_conn.send_bytes(json.dumps(result).encode('utf-8'))\n\n    finally:\n        # Final cleanup before subprocess exits\n        # Variables may already be deleted in try block, so use try/except\n        try:\n            del html_content\n        except (NameError, UnboundLocalError):\n            pass\n        try:\n            del result_data\n        except (NameError, UnboundLocalError):\n            pass\n        gc.collect()\n        pipe_conn.close()\n\n\ndef extract_itemprop_availability_safe(html_content) -> Restock:\n    \"\"\"\n    Extract itemprop availability with hybrid approach for memory efficiency.\n\n    Strategy (fastest to slowest, least to most memory):\n    1. Try pure Python extraction (JSON-LD, OpenGraph, microdata) - covers 80%+ of cases\n    2. Fall back to extruct with subprocess isolation on Linux for complex cases\n\n    Args:\n        html_content: HTML string to parse\n\n    Returns:\n        Restock: Extracted availability data\n\n    Raises:\n        MoreThanOnePriceFound: When multiple prices detected\n        Other exceptions: From extruct/parsing\n    \"\"\"\n    import platform\n\n    # Step 1: Try pure Python extraction first (fast, no lxml, no memory leak)\n    try:\n        from .pure_python_extractor import extract_metadata_pure_python, query_price_availability\n\n        logger.trace(\"Attempting pure Python metadata extraction (no lxml)\")\n        extracted_data = extract_metadata_pure_python(html_content)\n        price_data = query_price_availability(extracted_data)\n\n        # If we got price AND availability, we're done!\n        if price_data.get('price') and price_data.get('availability'):\n            result = Restock(price_data)\n            logger.debug(f\"Pure Python extraction successful: {dict(result)}\")\n            return result\n\n        # If we got some data but not everything, still try extruct for completeness\n        if price_data.get('price') or price_data.get('availability'):\n            logger.debug(f\"Pure Python extraction partial: {price_data}, will try extruct for completeness\")\n\n    except Exception as e:\n        logger.debug(f\"Pure Python extraction failed: {e}, falling back to extruct\")\n\n    # Step 2: Fall back to extruct (uses lxml, needs subprocess on Linux)\n    logger.trace(\"Falling back to extruct (lxml-based) with subprocess isolation\")\n\n    # Only use subprocess isolation on Linux\n    # Other platforms may have issues with spawn or don't need the aggressive memory management\n    if platform.system() == 'Linux':\n        import multiprocessing\n        import json\n        import gc\n\n        try:\n            ctx = multiprocessing.get_context('spawn')\n            parent_conn, child_conn = ctx.Pipe()\n            p = ctx.Process(target=_extract_itemprop_availability_worker, args=(child_conn,))\n            p.start()\n\n            # Send HTML as raw bytes (no pickle)\n            html_bytes = html_content.encode('utf-8')\n            parent_conn.send_bytes(html_bytes)\n\n            # Explicitly delete html_bytes copy immediately after sending\n            del html_bytes\n            gc.collect()\n\n            # Receive result as JSON\n            result_bytes = parent_conn.recv_bytes()\n            result = json.loads(result_bytes.decode('utf-8'))\n\n            # Wait for subprocess to complete\n            p.join()\n\n            # Close pipes\n            parent_conn.close()\n            child_conn.close()\n\n            # Clean up all subprocess-related objects\n            del p, parent_conn, child_conn, result_bytes\n            gc.collect()\n\n            # Handle result or re-raise exception\n            if result['success']:\n                # Reconstruct Restock object from dict\n                restock_obj = Restock(result['data'])\n                # Clean up result dict\n                del result\n                gc.collect()\n                return restock_obj\n            else:\n                # Re-raise the exception that occurred in subprocess\n                exception_type = result['exception_type']\n                exception_msg = result.get('exception_message', '')\n                del result\n                gc.collect()\n\n                if exception_type == 'MoreThanOnePriceFound':\n                    raise MoreThanOnePriceFound()\n                else:\n                    raise Exception(f\"{exception_type}: {exception_msg}\")\n\n        except Exception as e:\n            # If multiprocessing itself fails, log and fall back to direct call\n            logger.warning(f\"Subprocess extraction failed: {e}, falling back to direct call\")\n            gc.collect()\n            return get_itemprop_availability(html_content)\n    else:\n        # Non-Linux: direct call (no subprocess overhead needed)\n        return get_itemprop_availability(html_content)\n\n\n# should return Restock()\n# add casting?\ndef get_itemprop_availability(html_content) -> Restock:\n    \"\"\"\n    Kind of funny/cool way to find price/availability in one many different possibilities.\n    Use 'extruct' to find any possible RDFa/microdata/json-ld data, make a JSON string from the output then search it.\n    \"\"\"\n    from jsonpath_ng import parse\n\n    import re\n    now = time.time()\n    import extruct\n    logger.trace(f\"Imported extruct module in {time.time() - now:.3f}s\")\n\n    now = time.time()\n\n    # Extruct is very slow, I'm wondering if some ML is going to be faster (800ms on my i7), 'rdfa' seems to be the heaviest.\n    syntaxes = ['dublincore', 'json-ld', 'microdata', 'microformat', 'opengraph']\n    try:\n        data = extruct.extract(html_content, syntaxes=syntaxes)\n    except Exception as e:\n        logger.warning(f\"Unable to extract data, document parsing with extruct failed with {type(e).__name__} - {str(e)}\")\n        return Restock()\n\n    logger.trace(f\"Extruct basic extract of all metadata done in {time.time() - now:.3f}s\")\n\n    # First phase, dead simple scanning of anything that looks useful\n    value = Restock()\n    if data:\n        logger.debug(\"Using jsonpath to find price/availability/etc\")\n        price_parse = parse('$..(price|Price)')\n        pricecurrency_parse = parse('$..(pricecurrency|currency|priceCurrency )')\n        availability_parse = parse('$..(availability|Availability)')\n\n        price_result = _deduplicate_prices(price_parse.find(data))\n        if price_result:\n            # Right now, we just support single product items, maybe we will store the whole actual metadata seperately in teh future and\n            # parse that for the UI?\n            if len(price_result) > 1 and len(price_result) > 1:\n                # See of all prices are different, in the case that one product has many embedded data types with the same price\n                # One might have $121.95 and another 121.95 etc\n                logger.warning(f\"More than one price found {price_result}, throwing exception, cant use this plugin.\")\n                raise MoreThanOnePriceFound()\n\n            value['price'] = price_result[0]\n\n        pricecurrency_result = pricecurrency_parse.find(data)\n        if pricecurrency_result:\n            value['currency'] = pricecurrency_result[0].value\n\n        availability_result = availability_parse.find(data)\n        if availability_result:\n            value['availability'] = availability_result[0].value\n\n        if value.get('availability'):\n            value['availability'] = re.sub(r'(?i)^(https|http)://schema.org/', '',\n                                           value.get('availability').strip(' \"\\'').lower()) if value.get('availability') else None\n\n        # Second, go dig OpenGraph which is something that jsonpath_ng cant do because of the tuples and double-dots (:)\n        if not value.get('price') or value.get('availability'):\n            logger.debug(\"Alternatively digging through OpenGraph properties for restock/price info..\")\n            jsonpath_expr = parse('$..properties')\n\n            for match in jsonpath_expr.find(data):\n                if not value.get('price'):\n                    value['price'] = _search_prop_by_value([match.value], \"price:amount\")\n                if not value.get('availability'):\n                    value['availability'] = _search_prop_by_value([match.value], \"product:availability\")\n                if not value.get('currency'):\n                    value['currency'] = _search_prop_by_value([match.value], \"price:currency\")\n    logger.trace(f\"Processed with Extruct in {time.time()-now:.3f}s\")\n\n    return value\n\n\ndef is_between(number, lower=None, upper=None):\n    \"\"\"\n    Check if a number is between two values.\n\n    Parameters:\n    number (float): The number to check.\n    lower (float or None): The lower bound (inclusive). If None, no lower bound.\n    upper (float or None): The upper bound (inclusive). If None, no upper bound.\n\n    Returns:\n    bool: True if the number is between the lower and upper bounds, False otherwise.\n    \"\"\"\n    return (lower is None or lower <= number) and (upper is None or number <= upper)\n\n\nclass perform_site_check(difference_detection_processor):\n    screenshot = None\n    xpath_data = None\n\n    def run_changedetection(self, watch, force_reprocess=False):\n        import hashlib\n\n        if not watch:\n            raise Exception(\"Watch no longer exists.\")\n\n        current_raw_document_checksum = self.get_raw_document_checksum()\n        # Skip processing only if BOTH conditions are true:\n        # 1. HTML content unchanged (checksum matches last saved checksum)\n        # 2. Watch configuration was not edited (including trigger_text, filters, etc.)\n        # The was_edited flag handles all watch configuration changes, so we don't need\n        # separate checks for trigger_text or other processing rules.\n        if (not force_reprocess and\n            not watch.was_edited and\n            self.last_raw_content_checksum and\n            self.last_raw_content_checksum == current_raw_document_checksum):\n            raise checksumFromPreviousCheckWasTheSame()\n\n        # Unset any existing notification error\n        update_obj = {'last_notification_error': False, 'last_error': False, 'restock':  Restock()}\n\n        self.screenshot = self.fetcher.screenshot\n        self.xpath_data = self.fetcher.xpath_data\n\n        # Track the content type (readonly field, doesn't trigger was_edited)\n        update_obj['content-type'] = self.fetcher.headers.get('Content-Type', '')  # Use hyphen (matches OpenAPI spec)\n        update_obj[\"last_check_status\"] = self.fetcher.get_last_status_code()\n\n        # Save the raw content checksum to file (processor implementation detail, not watch config)\n        self.update_last_raw_content_checksum(current_raw_document_checksum)\n\n        # Only try to process restock information (like scraping for keywords) if the page was actually rendered correctly.\n        # Otherwise it will assume \"in stock\" because nothing suggesting the opposite was found\n#useless\n#        from ...html_tools import html_to_text\n#        text = html_to_text(self.fetcher.content)\n#        logger.debug(f\"Length of text after conversion: {len(text)}\")\n#        if not len(text):\n#            from ...content_fetchers.exceptions import ReplyWithContentButNoText\n#            raise ReplyWithContentButNoText(url=watch.link,\n#                                            status_code=self.fetcher.get_last_status_code(),\n#                                            screenshot=self.fetcher.screenshot,\n#                                            html_content=self.fetcher.content,\n#                                            xpath_data=self.fetcher.xpath_data\n#                                            )\n\n        # Which restock settings to compare against?\n        # Settings are stored in restock_diff.json (migrated from watch.json by update_30).\n        _extra_config = self.get_extra_watch_config('restock_diff.json')\n        restock_settings = _extra_config.get('restock_diff') or {\n            'follow_price_changes': True,\n            'in_stock_processing': 'in_stock_only',\n        }\n\n        # See if any tags have 'activate for individual watches in this tag/group?' enabled and use the first we find\n        for tag_uuid in watch.get('tags'):\n            tag = self.datastore.data['settings']['application']['tags'].get(tag_uuid, {})\n            if tag.get('overrides_watch'):\n                restock_settings = tag.get('processor_config_restock_diff') or {}\n                logger.info(f\"Watch {watch.get('uuid')} - Tag '{tag.get('title')}' selected for restock settings override\")\n                break\n\n\n        itemprop_availability = {}\n        multiple_prices_found = False\n\n        # Try built-in extraction first, this will scan metadata in the HTML\n        # On Linux, this runs in a subprocess to prevent lxml/extruct memory leaks\n        try:\n            itemprop_availability = extract_itemprop_availability_safe(self.fetcher.content)\n        except MoreThanOnePriceFound as e:\n            # Don't raise immediately - let plugins try to handle this case\n            # Plugins might be able to determine which price is correct\n            logger.warning(f\"Built-in detection found multiple prices on {watch.get('url')}, will try plugin override\")\n            multiple_prices_found = True\n            itemprop_availability = {}\n\n        # If built-in extraction didn't get both price AND availability, try plugin override\n        # Only check plugin if this watch is using a fetcher that might provide better data\n        has_price = itemprop_availability.get('price') is not None\n        has_availability = itemprop_availability.get('availability') is not None\n\n        # @TODO !!! some setting like \"Use as fallback\" or \"always use\", \"t\n        if not (has_price and has_availability) or True:\n            from changedetectionio.pluggy_interface import get_itemprop_availability_from_plugin\n            fetcher_name = watch.get('fetch_backend', 'html_requests')\n\n            # Resolve 'system' to the actual fetcher being used\n            # This allows plugins to work even when watch uses \"system settings default\"\n            if fetcher_name == 'system':\n                # Get the actual fetcher that was used (from self.fetcher)\n                # Fetcher class name gives us the actual backend (e.g., 'html_requests', 'html_webdriver')\n                actual_fetcher = type(self.fetcher).__name__\n                if 'html_requests' in actual_fetcher.lower():\n                    fetcher_name = 'html_requests'\n                elif 'webdriver' in actual_fetcher.lower() or 'playwright' in actual_fetcher.lower():\n                    fetcher_name = 'html_webdriver'\n                logger.debug(f\"Resolved 'system' fetcher to actual fetcher: {fetcher_name}\")\n\n            # Try plugin override - plugins can decide if they support this fetcher\n            if fetcher_name:\n                logger.debug(f\"Calling extra plugins for getting item price/availability (fetcher: {fetcher_name})\")\n                plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link)\n\n                if plugin_availability:\n                    # Plugin provided better data, use it\n                    plugin_has_price = plugin_availability.get('price') is not None\n                    plugin_has_availability = plugin_availability.get('availability') is not None\n\n                    # Only use plugin data if it's actually better than what we have\n                    if plugin_has_price or plugin_has_availability:\n                        itemprop_availability = plugin_availability\n                        logger.info(f\"Using plugin-provided availability data for fetcher '{fetcher_name}' (built-in had price={has_price}, availability={has_availability}; plugin has price={plugin_has_price}, availability={plugin_has_availability})\")\n                if not plugin_availability:\n                    logger.debug(\"No item price/availability from plugins\")\n\n        # If we had multiple prices and plugins also failed, NOW raise the exception\n        if multiple_prices_found and not itemprop_availability.get('price'):\n            raise ProcessorException(\n                message=\"Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.\",\n                url=watch.get('url'),\n                status_code=self.fetcher.get_last_status_code(),\n                screenshot=self.fetcher.screenshot,\n                xpath_data=self.fetcher.xpath_data\n            )\n\n        # Something valid in get_itemprop_availability() by scraping metadata ?\n        if itemprop_availability.get('price') or itemprop_availability.get('availability'):\n            # Store for other usage\n            update_obj['restock'] = itemprop_availability\n\n            if itemprop_availability.get('availability'):\n                # @todo: Configurable?\n                if any(substring.lower() in itemprop_availability['availability'].lower() for substring in [\n                    'instock',\n                    'instoreonly',\n                    'limitedavailability',\n                    'onlineonly',\n                    'presale']\n                       ):\n                    update_obj['restock']['in_stock'] = True\n                else:\n                    update_obj['restock']['in_stock'] = False\n\n        # Main detection method\n        fetched_md5 = None\n\n        # store original price if not set\n        if itemprop_availability and itemprop_availability.get('price') and not itemprop_availability.get('original_price'):\n            itemprop_availability['original_price'] = itemprop_availability.get('price')\n            update_obj['restock'][\"original_price\"] = itemprop_availability.get('price')\n\n        if not self.fetcher.instock_data and not itemprop_availability.get('availability') and not itemprop_availability.get('price'):\n            raise ProcessorException(\n                message=f\"Unable to extract restock data for this page unfortunately. (Got code {self.fetcher.get_last_status_code()} from server), no embedded stock information was found and nothing interesting in the text, try using this watch with Chrome.\",\n                url=watch.get('url'),\n                status_code=self.fetcher.get_last_status_code(),\n                screenshot=self.fetcher.screenshot,\n                xpath_data=self.fetcher.xpath_data\n                )\n\n        logger.debug(f\"self.fetcher.instock_data is - '{self.fetcher.instock_data}' and itemprop_availability.get('availability') is {itemprop_availability.get('availability')}\")\n        # Nothing automatic in microdata found, revert to scraping the page\n        if self.fetcher.instock_data and itemprop_availability.get('availability') is None:\n            # 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.\n            # Careful! this does not really come from chrome/js when the watch is set to plaintext\n            update_obj['restock'][\"in_stock\"] = True if self.fetcher.instock_data == 'Possibly in stock' else False\n            logger.debug(f\"Watch UUID {watch.get('uuid')} restock check returned instock_data - '{self.fetcher.instock_data}' from JS scraper.\")\n\n        # Very often websites will lie about the 'availability' in the metadata, so if the scraped version says its NOT in stock, use that.\n        if self.fetcher.instock_data and self.fetcher.instock_data != 'Possibly in stock':\n            if update_obj['restock'].get('in_stock'):\n                logger.warning(\n                    f\"Lie detected in the availability machine data!! when scraping said its not in stock!! itemprop was '{itemprop_availability}' and scraped from browser was '{self.fetcher.instock_data}' update obj was {update_obj['restock']} \")\n                logger.warning(f\"Setting instock to FALSE, scraper found '{self.fetcher.instock_data}' in the body but metadata reported not-in-stock\")\n                update_obj['restock'][\"in_stock\"] = False\n\n        # What we store in the snapshot\n        price = update_obj.get('restock').get('price') if update_obj.get('restock').get('price') else \"\"\n        snapshot_content = f\"In Stock: {update_obj.get('restock').get('in_stock')} - Price: {price}\"\n\n        # Main detection method\n        fetched_md5 = hashlib.md5(snapshot_content.encode('utf-8')).hexdigest()\n\n        # The main thing that all this at the moment comes down to :)\n        changed_detected = False\n        logger.debug(f\"Watch UUID {watch.get('uuid')} restock check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}\")\n\n        # out of stock -> back in stock only?\n        if watch.get('restock') and watch['restock'].get('in_stock') != update_obj['restock'].get('in_stock'):\n            # Yes if we only care about it going to instock, AND we are in stock\n            if restock_settings.get('in_stock_processing') == 'in_stock_only' and update_obj['restock']['in_stock']:\n                changed_detected = True\n\n            if restock_settings.get('in_stock_processing') == 'all_changes':\n                # All cases\n                changed_detected = True\n\n        if restock_settings.get('follow_price_changes') and watch.get('restock') and update_obj.get('restock') and update_obj['restock'].get('price'):\n            price = float(update_obj['restock'].get('price'))\n            # Default to current price if no previous price found\n            if watch['restock'].get('original_price'):\n                previous_price = float(watch['restock'].get('original_price'))\n                # It was different, but negate it further down\n                if price != previous_price:\n                    changed_detected = True\n\n            # Minimum/maximum price limit\n            if update_obj.get('restock') and update_obj['restock'].get('price'):\n                logger.debug(\n                    f\"{watch.get('uuid')} - Change was detected, 'price_change_max' is '{restock_settings.get('price_change_max', '')}' 'price_change_min' is '{restock_settings.get('price_change_min', '')}', price from website is '{update_obj['restock'].get('price', '')}'.\")\n                if update_obj['restock'].get('price'):\n                    min_limit = float(restock_settings.get('price_change_min')) if restock_settings.get('price_change_min') else None\n                    max_limit = float(restock_settings.get('price_change_max')) if restock_settings.get('price_change_max') else None\n\n                    price = float(update_obj['restock'].get('price'))\n                    logger.debug(f\"{watch.get('uuid')} after float conversion - Min limit: '{min_limit}' Max limit: '{max_limit}' Price: '{price}'\")\n                    if min_limit or max_limit:\n                        if is_between(number=price, lower=min_limit, upper=max_limit):\n                            # Price was between min/max limit, so there was nothing todo in any case\n                            logger.trace(f\"{watch.get('uuid')} {price} is between {min_limit} and {max_limit}, nothing to check, forcing changed_detected = False (was {changed_detected})\")\n                            changed_detected = False\n                        else:\n                            logger.trace(f\"{watch.get('uuid')} {price} is between {min_limit} and {max_limit}, continuing normal comparison\")\n\n                    # Price comparison by %\n                    if watch['restock'].get('original_price') and changed_detected and restock_settings.get('price_change_threshold_percent'):\n                        previous_price = float(watch['restock'].get('original_price'))\n                        pc = float(restock_settings.get('price_change_threshold_percent'))\n                        change = abs((price - previous_price) / previous_price * 100)\n                        if change and change <= pc:\n                            logger.debug(f\"{watch.get('uuid')} Override change-detected to FALSE because % threshold ({pc}%) was {change:.3f}%\")\n                            changed_detected = False\n                        else:\n                            logger.debug(f\"{watch.get('uuid')} Price change was {change:.3f}% , (threshold {pc}%)\")\n\n        # Always record the new checksum\n        update_obj[\"previous_md5\"] = fetched_md5\n\n        return changed_detected, update_obj, snapshot_content.strip()\n"
  },
  {
    "path": "changedetectionio/processors/restock_diff/pure_python_extractor.py",
    "content": "\"\"\"\nPure Python metadata extractor - no lxml, no memory leaks.\n\nThis module provides a fast, memory-efficient alternative to extruct for common\ne-commerce metadata extraction. It handles:\n- JSON-LD (covers 80%+ of modern sites)\n- OpenGraph meta tags\n- Basic microdata attributes\n\nUses Python's built-in html.parser instead of lxml/libxml2, avoiding C-level\nmemory allocation issues. For edge cases, the main processor can fall back to\nextruct (with subprocess isolation on Linux).\n\"\"\"\n\nfrom html.parser import HTMLParser\nimport json\nimport re\nfrom loguru import logger\n\n\nclass JSONLDExtractor(HTMLParser):\n    \"\"\"\n    Extract JSON-LD structured data from HTML.\n\n    Finds all <script type=\"application/ld+json\"> tags and parses their content.\n    Handles multiple JSON-LD blocks on the same page.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.in_jsonld = False\n        self.data = []  # List of all parsed JSON-LD objects\n        self.current_script = []\n\n    def handle_starttag(self, tag, attrs):\n        if tag == 'script':\n            # Check if this is a JSON-LD script tag\n            for attr, value in attrs:\n                if attr == 'type' and value == 'application/ld+json':\n                    self.in_jsonld = True\n                    self.current_script = []\n                    break\n\n    def handle_data(self, data):\n        if self.in_jsonld:\n            self.current_script.append(data)\n\n    def handle_endtag(self, tag):\n        if tag == 'script' and self.in_jsonld:\n            # Parse the accumulated script content\n            script_content = ''.join(self.current_script)\n            if script_content.strip():\n                try:\n                    # Parse JSON (handles both objects and arrays)\n                    parsed = json.loads(script_content)\n                    if isinstance(parsed, list):\n                        self.data.extend(parsed)\n                    else:\n                        self.data.append(parsed)\n                except json.JSONDecodeError as e:\n                    logger.debug(f\"Failed to parse JSON-LD: {e}\")\n                    pass\n\n            self.in_jsonld = False\n            self.current_script = []\n\n\nclass OpenGraphExtractor(HTMLParser):\n    \"\"\"\n    Extract OpenGraph meta tags from HTML.\n\n    Finds <meta property=\"og:*\"> tags commonly used for social media sharing.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.og_data = {}\n\n    def handle_starttag(self, tag, attrs):\n        if tag == 'meta':\n            attrs_dict = dict(attrs)\n            prop = attrs_dict.get('property', '')\n\n            # Extract OpenGraph properties\n            if prop.startswith('og:'):\n                content = attrs_dict.get('content', '')\n                if content:\n                    self.og_data[prop] = content\n\n\nclass MicrodataExtractor(HTMLParser):\n    \"\"\"\n    Extract basic microdata attributes from HTML.\n\n    Finds elements with itemprop attributes. This is a simplified extractor\n    that doesn't handle nested itemscope/itemtype hierarchies - for complex\n    cases, use extruct as fallback.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.microdata = {}\n        self.current_itemprop = None\n\n    def handle_starttag(self, tag, attrs):\n        attrs_dict = dict(attrs)\n\n        if 'itemprop' in attrs_dict:\n            itemprop = attrs_dict['itemprop']\n\n            # Price/currency/availability can be in content/href attributes\n            if itemprop == 'price':\n                if 'content' in attrs_dict:\n                    self.microdata['price'] = attrs_dict['content']\n                else:\n                    self.current_itemprop = 'price'\n\n            elif itemprop == 'priceCurrency':\n                if 'content' in attrs_dict:\n                    self.microdata['currency'] = attrs_dict['content']\n                else:\n                    self.current_itemprop = 'priceCurrency'\n\n            elif itemprop == 'availability':\n                # Can be in href (link) or content (meta)\n                if 'href' in attrs_dict:\n                    self.microdata['availability'] = attrs_dict['href']\n                elif 'content' in attrs_dict:\n                    self.microdata['availability'] = attrs_dict['content']\n                else:\n                    self.current_itemprop = 'availability'\n\n    def handle_data(self, data):\n        # Capture text content for itemprop elements\n        if self.current_itemprop == 'price':\n            # Try to extract numeric price from text\n            try:\n                price_text = re.sub(r'[^\\d.]', '', data.strip())\n                if price_text:\n                    self.microdata['price'] = float(price_text)\n            except ValueError:\n                pass\n        elif self.current_itemprop == 'priceCurrency':\n            currency = data.strip()\n            if currency:\n                self.microdata['currency'] = currency\n        elif self.current_itemprop == 'availability':\n            availability = data.strip()\n            if availability:\n                self.microdata['availability'] = availability\n\n    def handle_endtag(self, tag):\n        # Reset current itemprop after closing tag\n        self.current_itemprop = None\n\n\ndef extract_metadata_pure_python(html_content):\n    \"\"\"\n    Extract structured metadata from HTML using pure Python parsers.\n\n    Returns a dict with three keys:\n    - 'json-ld': List of parsed JSON-LD objects\n    - 'opengraph': Dict of OpenGraph properties\n    - 'microdata': Dict of microdata properties\n\n    Args:\n        html_content: HTML string to parse\n\n    Returns:\n        dict: Extracted metadata in three formats\n    \"\"\"\n    result = {\n        'json-ld': [],\n        'opengraph': {},\n        'microdata': {}\n    }\n\n    # Extract JSON-LD\n    try:\n        jsonld_extractor = JSONLDExtractor()\n        jsonld_extractor.feed(html_content)\n        result['json-ld'] = jsonld_extractor.data\n        logger.trace(f\"Pure Python: Found {len(jsonld_extractor.data)} JSON-LD blocks\")\n    except Exception as e:\n        logger.debug(f\"JSON-LD extraction failed: {e}\")\n\n    # Extract OpenGraph\n    try:\n        og_extractor = OpenGraphExtractor()\n        og_extractor.feed(html_content)\n        result['opengraph'] = og_extractor.og_data\n        if result['opengraph']:\n            logger.trace(f\"Pure Python: Found {len(og_extractor.og_data)} OpenGraph tags\")\n    except Exception as e:\n        logger.debug(f\"OpenGraph extraction failed: {e}\")\n\n    # Extract Microdata\n    try:\n        microdata_extractor = MicrodataExtractor()\n        microdata_extractor.feed(html_content)\n        result['microdata'] = microdata_extractor.microdata\n        if result['microdata']:\n            logger.trace(f\"Pure Python: Found microdata: {result['microdata']}\")\n    except Exception as e:\n        logger.debug(f\"Microdata extraction failed: {e}\")\n\n    return result\n\n\ndef query_price_availability(extracted_data):\n    \"\"\"\n    Query extracted metadata for price and availability information.\n\n    Uses jsonpath_ng to query JSON-LD data (same approach as extruct).\n    Falls back to OpenGraph and microdata if JSON-LD doesn't have the data.\n\n    Args:\n        extracted_data: Dict from extract_metadata_pure_python()\n\n    Returns:\n        dict: {'price': float, 'currency': str, 'availability': str}\n    \"\"\"\n    from jsonpath_ng import parse\n\n    result = {}\n\n    # 1. Try JSON-LD first (most reliable and common)\n    for data in extracted_data.get('json-ld', []):\n        try:\n            # Use jsonpath to find price/availability anywhere in the structure\n            price_parse = parse('$..(price|Price)')\n            availability_parse = parse('$..(availability|Availability)')\n            currency_parse = parse('$..(priceCurrency|currency|priceCurrency)')\n\n            price_results = [m.value for m in price_parse.find(data)]\n            if price_results and not result.get('price'):\n                # Handle various price formats\n                price_val = price_results[0]\n                if isinstance(price_val, (int, float)):\n                    result['price'] = float(price_val)\n                elif isinstance(price_val, str):\n                    # Extract numeric value from string\n                    try:\n                        result['price'] = float(re.sub(r'[^\\d.]', '', price_val))\n                    except ValueError:\n                        pass\n\n            avail_results = [m.value for m in availability_parse.find(data)]\n            if avail_results and not result.get('availability'):\n                result['availability'] = str(avail_results[0])\n\n            curr_results = [m.value for m in currency_parse.find(data)]\n            if curr_results and not result.get('currency'):\n                result['currency'] = str(curr_results[0])\n\n            # If we found price, this JSON-LD block is good\n            if result.get('price'):\n                logger.debug(f\"Pure Python: Found price data in JSON-LD: {result}\")\n                break\n\n        except Exception as e:\n            logger.debug(f\"Error querying JSON-LD: {e}\")\n            continue\n\n    # 2. Try OpenGraph if JSON-LD didn't provide everything\n    og_data = extracted_data.get('opengraph', {})\n    if not result.get('price') and 'og:price:amount' in og_data:\n        try:\n            result['price'] = float(og_data['og:price:amount'])\n        except ValueError:\n            pass\n    if not result.get('currency') and 'og:price:currency' in og_data:\n        result['currency'] = og_data['og:price:currency']\n    if not result.get('availability') and 'og:availability' in og_data:\n        result['availability'] = og_data['og:availability']\n\n    # 3. Use microdata as last resort\n    microdata = extracted_data.get('microdata', {})\n    if not result.get('price') and 'price' in microdata:\n        result['price'] = microdata['price']\n    if not result.get('currency') and 'currency' in microdata:\n        result['currency'] = microdata['currency']\n    if not result.get('availability') and 'availability' in microdata:\n        result['availability'] = microdata['availability']\n\n    # result['price'] could be float or str here, depending on the website, for example it might contain \"1,00\" commas, etc.\n    # using something like babel you need to know the locale of the website and even then it can be problematic\n    # we dont really do anything with the price data so far.. so just accept it the way it comes.\n    return result\n"
  },
  {
    "path": "changedetectionio/processors/templates/extract.html",
    "content": "{% extends 'base.html' %}\n{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}\n{% block content %}\n    <div class=\"tabs\">\n    <ul>\n        {% if last_error_text %}<li class=\"tab\" id=\"error-text-tab\"><a href=\"{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#error-text\">Error Text</a></li> {% endif %}\n        {% if last_error_screenshot %}<li class=\"tab\" id=\"error-screenshot-tab\"><a href=\"{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#error-screenshot\">Error Screenshot</a></li> {% endif %}\n        <li class=\"tab\" id=\"\"><a href=\"{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#text\">Text</a></li>\n        <li class=\"tab\" id=\"screenshot-tab\"><a href=\"{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid)}}#screenshot\">Screenshot</a></li>\n        <li class=\"tab active\" id=\"extract-tab\"><a href=\"{{ url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)}}\">Extract Data</a></li>\n    </ul>\n</div>\n\n    <div id=\"diff-ui\">\n\n <div class=\"xxxxtab-pane-inner\" id=\"extract\">\n        <form id=\"extract-data-form\" class=\"pure-form pure-form-stacked edit-form\"  action=\"{{ url_for('ui.ui_diff.diff_history_page_extract_POST', uuid=uuid) }}\"  method=\"POST\">\n            <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\">\n\n            <p>This tool will extract text data from all of the watch history.</p>\n\n            <div class=\"pure-control-group\">\n                {{ render_field(extract_form.extract_regex) }}\n                <span class=\"pure-form-message-inline\">\n                    A <strong>RegEx</strong> is a pattern that identifies exactly which part inside of the text that you want to extract.<br>\n\n                    <p>\n                        For example, to extract only the numbers from text &dash;<br>\n                        <strong>Raw text</strong>: <code>Temperature <span style=\"color: red\">5.5</span>°C in Sydney</code><br>\n                        <strong>RegEx to extract:</strong> <code>Temperature <span style=\"color: red\">([0-9\\.]+)</span></code><br>\n                    </p>\n                    <p>\n                        <a href=\"https://RegExr.com/\">Be sure to test your RegEx here.</a>\n                    </p>\n                    <p>\n                        Each RegEx group bracket <code>()</code> will be in its own column, the first column value is always the date.\n                    </p>\n                </span>\n            </div>\n            <div class=\"pure-control-group\">\n                {{ render_button(extract_form.extract_submit_button) }}\n            </div>\n        </form>\n    </div>\n</div>\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/processors/text_json_diff/__init__.py",
    "content": "from loguru import logger\n\n# Processor capabilities\nsupports_visual_selector = True\nsupports_browser_steps = True\nsupports_text_filters_and_triggers = True\nsupports_text_filters_and_triggers_elements = True\nsupports_request_type = True\n\n\n\ndef _task(watch, update_handler):\n    from changedetectionio.content_fetchers.exceptions import ReplyWithContentButNoText\n    from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse\n\n    text_after_filter = ''\n\n    try:\n        # The slow process (we run 2 of these in parallel)\n        # Always force reprocess for preview - we want to show the filtered content regardless of checksums\n        changed_detected, update_obj, text_after_filter = update_handler.run_changedetection(watch=watch, force_reprocess=True)\n    except FilterNotFoundInResponse as e:\n        text_after_filter = f\"Filter not found in HTML: {str(e)}\"\n    except ReplyWithContentButNoText as e:\n        text_after_filter = \"Filter found but no text (empty result)\"\n    except Exception as e:\n        text_after_filter = f\"Error: {str(e)}\"\n\n    if not text_after_filter.strip():\n        text_after_filter = 'Empty content'\n\n    # because run_changedetection always returns bytes due to saving the snapshots etc\n    text_after_filter = text_after_filter.decode('utf-8') if isinstance(text_after_filter, bytes) else text_after_filter\n\n    return text_after_filter\n\n\ndef prepare_filter_prevew(datastore, watch_uuid, form_data):\n    '''Used by @app.route(\"/edit/<uuid_str:uuid>/preview-rendered\", methods=['POST'])'''\n    from changedetectionio import forms, html_tools\n    from changedetectionio.model.Watch import model as watch_model\n    from concurrent.futures import ThreadPoolExecutor\n    from copy import deepcopy\n    from flask import request\n    import brotli\n    import importlib\n    import os\n    import time\n    now = time.time()\n\n    text_after_filter = ''\n    text_before_filter = ''\n    trigger_line_numbers = []\n    ignore_line_numbers = []\n    blocked_line_numbers = []\n\n    tmp_watch = deepcopy(datastore.data['watching'].get(watch_uuid))\n\n    if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.data_dir):\n        # Splice in the temporary stuff from the form\n        form = forms.processor_text_json_diff_form(formdata=form_data if request.method == 'POST' else None,\n                                                   data=form_data\n                                                   )\n\n        # Only update vars that came in via the AJAX post\n        p = {k: v for k, v in form.data.items() if k in form_data.keys()}\n        tmp_watch.update(p)\n        blank_watch_no_filters = watch_model(datastore_path=datastore.datastore_path, __datastore=datastore.data)\n        blank_watch_no_filters['url'] = tmp_watch.get('url')\n\n        latest_filename = next(reversed(tmp_watch.history))\n        html_fname = os.path.join(tmp_watch.data_dir, f\"{latest_filename}.html.br\")\n        with open(html_fname, 'rb') as f:\n            decompressed_data = brotli.decompress(f.read()).decode('utf-8') if html_fname.endswith('.br') else f.read().decode('utf-8')\n\n            # Just like a normal change detection except provide a fake \"watch\" object and dont call .call_browser()\n            processor_module = importlib.import_module(\"changedetectionio.processors.text_json_diff.processor\")\n            update_handler = processor_module.perform_site_check(datastore=datastore,\n                                                                 watch_uuid=tmp_watch.get('uuid')  # probably not needed anymore anyway?\n                                                                 )\n            # Use the last loaded HTML as the input\n            update_handler.datastore = datastore\n            update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string\n            update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')\n\n            # Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk\n            # Do this as parallel threads (not processes) to avoid pickle issues with Lock objects\n            try:\n                with ThreadPoolExecutor(max_workers=2) as executor:\n                    future1 = executor.submit(_task, tmp_watch, update_handler)\n                    future2 = executor.submit(_task, blank_watch_no_filters, update_handler)\n\n                    text_after_filter = future1.result()\n                    text_before_filter = future2.result()\n            except Exception as e:\n                x=1\n\n    try:\n        trigger_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,\n                                                            wordlist=tmp_watch['trigger_text'],\n                                                            mode='line numbers'\n                                                            )\n    except Exception as e:\n        text_before_filter = f\"Error: {str(e)}\"\n\n    try:\n        text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', [])\n        ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,\n                                                           wordlist=text_to_ignore,\n                                                           mode='line numbers'\n                                                           )\n    except Exception as e:\n        text_before_filter = f\"Error: {str(e)}\"\n\n    try:\n        blocked_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,\n                                                           wordlist=tmp_watch.get('text_should_not_be_present', []) + datastore.data['settings']['application'].get('text_should_not_be_present', []),\n                                                           mode='line numbers'\n                                                           )\n    except Exception as e:\n        text_before_filter = f\"Error: {str(e)}\"\n\n    logger.trace(f\"Parsed in {time.time() - now:.3f}s\")\n\n    return ({\n        'after_filter': text_after_filter,\n        'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,\n        'blocked_line_numbers': blocked_line_numbers,\n        'duration': time.time() - now,\n        'ignore_line_numbers': ignore_line_numbers,\n        'trigger_line_numbers': trigger_line_numbers,\n        })\n\n\n"
  },
  {
    "path": "changedetectionio/processors/text_json_diff/difference.py",
    "content": "\"\"\"\nHistory/diff rendering for text_json_diff processor.\n\nThis module handles the visualization of text/HTML/JSON changes by rendering\na side-by-side or unified diff view with syntax highlighting and change markers.\n\"\"\"\n\nimport os\nimport time\nfrom loguru import logger\n\nfrom changedetectionio import diff, strtobool\nfrom changedetectionio.diff import (\n    REMOVED_STYLE, ADDED_STYLE, REMOVED_INNER_STYLE, ADDED_INNER_STYLE,\n    REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED,\n    ADDED_PLACEMARKER_OPEN, ADDED_PLACEMARKER_CLOSED,\n    CHANGED_PLACEMARKER_OPEN, CHANGED_PLACEMARKER_CLOSED,\n    CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED\n)\nfrom changedetectionio.notification.handler import apply_html_color_to_body\n\n\ndef build_diff_cell_visualizer(content, resolution=100):\n    \"\"\"\n    Build a visual cell grid for the diff visualizer.\n\n    Analyzes the content for placemarkers indicating changes and creates a\n    grid of cells representing the document, with each cell marked as:\n    - 'deletion' for removed content\n    - 'insertion' for added content\n    - 'mixed' for cells containing both deletions and insertions\n    - empty string for cells with no changes\n\n    Args:\n        content: The diff content with placemarkers\n        resolution: Number of cells to create (default 100)\n\n    Returns:\n        List of dicts with 'class' key for each cell's CSS class\n    \"\"\"\n    if not content:\n        return [{'class': ''} for _ in range(resolution)]\n    now = time.time()\n    # Work with character positions for better accuracy\n    content_length = len(content)\n\n    if content_length == 0:\n        return [{'class': ''} for _ in range(resolution)]\n\n    chars_per_cell = max(1, content_length / resolution)\n\n    # Track change type for each cell\n    cell_data = {}\n\n    # Placemarkers to detect\n    change_markers = {\n        REMOVED_PLACEMARKER_OPEN: 'deletion',\n        ADDED_PLACEMARKER_OPEN: 'insertion',\n        CHANGED_PLACEMARKER_OPEN: 'deletion',\n        CHANGED_INTO_PLACEMARKER_OPEN: 'insertion',\n    }\n\n    # Find all occurrences of each marker\n    for marker, change_type in change_markers.items():\n        pos = 0\n        while True:\n            pos = content.find(marker, pos)\n            if pos == -1:\n                break\n\n            # Calculate which cell this marker falls into\n            cell_index = min(int(pos / chars_per_cell), resolution - 1)\n\n            if cell_index not in cell_data:\n                cell_data[cell_index] = change_type\n            elif cell_data[cell_index] != change_type:\n                # Mixed changes in this cell\n                cell_data[cell_index] = 'mixed'\n\n            pos += len(marker)\n\n    # Build the cell list\n    cells = []\n    for i in range(resolution):\n        change_type = cell_data.get(i, '')\n        cells.append({'class': change_type})\n\n    logger.debug(f\"Built diff cell visualizer: {len([c for c in cells if c['class']])} cells with changes out of {resolution} in {time.time() - now:.2f}s\")\n\n    return cells\n\n# Diff display preferences configuration - single source of truth\nDIFF_PREFERENCES_CONFIG = {\n    'changesOnly': {'default': True, 'type': 'bool'},\n    'ignoreWhitespace': {'default': False, 'type': 'bool'},\n    'removed': {'default': True, 'type': 'bool'},\n    'added': {'default': True, 'type': 'bool'},\n    'replaced': {'default': True, 'type': 'bool'},\n    'type': {'default': 'diffLines', 'type': 'value'},\n}\n\ndef render(watch, datastore, request, url_for, render_template, flash, redirect, extract_form=None):\n    \"\"\"\n    Render the history/diff view for text/JSON/HTML changes.\n\n    Args:\n        watch: The watch object\n        datastore: The ChangeDetectionStore instance\n        request: Flask request object\n        url_for: Flask url_for function\n        render_template: Flask render_template function\n        flash: Flask flash function\n        redirect: Flask redirect function\n        extract_form: Optional pre-built extract form (for error cases)\n\n    Returns:\n        Rendered HTML response\n    \"\"\"\n    from changedetectionio import forms\n\n    uuid = watch.get('uuid')\n\n    extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]\n\n    # Use provided form or create a new one\n    if extract_form is None:\n        extract_form = forms.extractDataForm(formdata=request.form,\n                                             data={'extract_regex': request.form.get('extract_regex', '')}\n                                             )\n    history = watch.history\n    dates = list(history.keys())\n\n    # If a \"from_version\" was requested, then find it (or the closest one)\n    # Also set \"from version\" to be the closest version to the one that was last viewed.\n\n    best_last_viewed_timestamp = watch.get_from_version_based_on_last_viewed\n    from_version_timestamp = best_last_viewed_timestamp if best_last_viewed_timestamp else dates[-2]\n    from_version = request.args.get('from_version', from_version_timestamp )\n\n    # Use the current one if nothing was specified\n    to_version = request.args.get('to_version', str(dates[-1]))\n\n    try:\n        to_version_file_contents = watch.get_history_snapshot(timestamp=to_version)\n    except Exception as e:\n        logger.error(f\"Unable to read watch history to-version for version {to_version}: {str(e)}\")\n        to_version_file_contents = f\"Unable to read to-version at {to_version}.\\n\"\n\n    try:\n        from_version_file_contents = watch.get_history_snapshot(timestamp=from_version)\n    except Exception as e:\n        logger.error(f\"Unable to read watch history from-version for version {from_version}: {str(e)}\")\n        from_version_file_contents = f\"Unable to read to-version {from_version}.\\n\"\n\n    screenshot_url = watch.get_screenshot()\n\n    is_html_webdriver = watch.fetcher_supports_screenshots\n\n    password_enabled_and_share_is_off = False\n    if datastore.data['settings']['application'].get('password') or os.getenv(\"SALTED_PASS\", False):\n        password_enabled_and_share_is_off = not datastore.data['settings']['application'].get('shared_diff_access')\n\n    datastore.set_last_viewed(uuid, time.time())\n\n    # Parse diff preferences from request using config as single source of truth\n    # Check if this is a user submission (any diff pref param exists in query string)\n    user_submitted = any(key in request.args for key in DIFF_PREFERENCES_CONFIG.keys())\n\n    diff_prefs = {}\n    for key, config in DIFF_PREFERENCES_CONFIG.items():\n        if user_submitted:\n            # User submitted form - missing checkboxes are explicitly OFF\n            if config['type'] == 'bool':\n                diff_prefs[key] = strtobool(request.args.get(key, 'off'))\n            else:\n                diff_prefs[key] = request.args.get(key, config['default'])\n        else:\n            # Initial load - use defaults from config\n            diff_prefs[key] = config['default']\n\n    content = diff.render_diff(previous_version_file_contents=from_version_file_contents,\n                               newest_version_file_contents=to_version_file_contents,\n                               include_replaced=diff_prefs['replaced'],\n                               include_added=diff_prefs['added'],\n                               include_removed=diff_prefs['removed'],\n                               include_equal=diff_prefs['changesOnly'],\n                               ignore_junk=diff_prefs['ignoreWhitespace'],\n                               word_diff=diff_prefs['type'] == 'diffWords',\n                               )\n\n    # Build cell grid visualizer before applying HTML color (so we can detect placemarkers)\n    diff_cell_grid = build_diff_cell_visualizer(content)\n\n    content = apply_html_color_to_body(n_body=content)\n    offscreen_content = render_template(\"diff-offscreen-options.html\")\n\n    note = ''\n    if str(from_version) != str(dates[-2]) or str(to_version) != str(dates[-1]):\n        note = 'Note: You are not viewing the latest changes.'\n\n    output = render_template(\"diff.html\",\n                             #initial_scroll_line_number=100,\n                             bottom_horizontal_offscreen_contents=offscreen_content,\n                             content=content,\n                             current_diff_url=watch['url'],\n                             diff_cell_grid=diff_cell_grid,\n                             diff_prefs=diff_prefs,\n                             extra_classes='difference-page',\n                             extra_stylesheets=extra_stylesheets,\n                             extra_title=f\" - {watch.label} - History\",\n                             extract_form=extract_form,\n                             from_version=str(from_version),\n                             is_html_webdriver=is_html_webdriver,\n                             last_error=watch['last_error'],\n                             last_error_screenshot=watch.get_error_snapshot(),\n                             last_error_text=watch.get_error_text(),\n                             newest=to_version_file_contents,\n                             newest_version_timestamp=dates[-1],\n                             note=note,\n                             password_enabled_and_share_is_off=password_enabled_and_share_is_off,\n                             pure_menu_fixed=False,\n                             screenshot=screenshot_url,\n                             to_version=str(to_version),\n                             uuid=uuid,\n                             versions=dates,  # All except current/last\n                             watch_a=watch,\n                             )\n    return output\n"
  },
  {
    "path": "changedetectionio/processors/text_json_diff/processor.py",
    "content": "# HTML to TEXT/JSON DIFFERENCE self.fetcher\n\nimport hashlib\nimport json\nimport os\nimport re\nimport urllib3\n\nfrom changedetectionio.conditions import execute_ruleset_against_all_plugins\nfrom changedetectionio.content_fetchers.exceptions import checksumFromPreviousCheckWasTheSame\nfrom ..base import difference_detection_processor\nfrom changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE\nfrom changedetectionio import html_tools, content_fetchers\nfrom changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT\nfrom loguru import logger\n\nfrom changedetectionio.processors.magic import guess_stream_type\n\nurllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n\n# Translation marker for extraction - allows pybabel to find these strings\ndef _(x): return x\nname = _('Webpage Text/HTML, JSON and PDF changes')\ndescription = _('Detects all text changes where possible')\ndel _  # Remove marker\nprocessor_weight = -100\nlist_badge_text = \"Text\"\n\nJSON_FILTER_PREFIXES = ['json:', 'jq:', 'jqraw:']\n\n# Assume it's this type if the server says nothing on content-type\nDEFAULT_WHEN_NO_CONTENT_TYPE_HEADER = 'text/html'\n\nclass FilterNotFoundInResponse(ValueError):\n    def __init__(self, msg, screenshot=None, xpath_data=None):\n        self.screenshot = screenshot\n        self.xpath_data = xpath_data\n        ValueError.__init__(self, msg)\n\n\nclass PDFToHTMLToolNotFound(ValueError):\n    def __init__(self, msg):\n        ValueError.__init__(self, msg)\n\n\nclass FilterConfig:\n    \"\"\"Consolidates all filter and rule configurations from watch, tags, and global settings.\"\"\"\n\n    def __init__(self, watch, datastore):\n        self.watch = watch\n        self.datastore = datastore\n        self.watch_uuid = watch.get('uuid')\n        # Cache computed properties to avoid repeated list operations\n        self._include_filters_cache = None\n        self._subtractive_selectors_cache = None\n\n    def _get_merged_rules(self, attr, include_global=False):\n        \"\"\"Merge rules from watch, tags, and optionally global settings.\"\"\"\n        watch_rules = self.watch.get(attr, [])\n        tag_rules = self.datastore.get_tag_overrides_for_watch(uuid=self.watch_uuid, attr=attr)\n        rules = list(dict.fromkeys(watch_rules + tag_rules))\n\n        if include_global:\n            global_rules = self.datastore.data['settings']['application'].get(f'global_{attr}', [])\n            rules = list(dict.fromkeys(rules + global_rules))\n\n        return rules\n\n    @property\n    def include_filters(self):\n        if self._include_filters_cache is None:\n            filters = self._get_merged_rules('include_filters')\n            # Inject LD+JSON price tracker rule if enabled\n            if self.watch.get('track_ldjson_price_data', '') == PRICE_DATA_TRACK_ACCEPT:\n                filters += html_tools.LD_JSON_PRODUCT_OFFER_SELECTORS\n            self._include_filters_cache = filters\n        return self._include_filters_cache\n\n    @property\n    def subtractive_selectors(self):\n        if self._subtractive_selectors_cache is None:\n            watch_selectors = self.watch.get(\"subtractive_selectors\", [])\n            tag_selectors = self.datastore.get_tag_overrides_for_watch(uuid=self.watch_uuid, attr='subtractive_selectors')\n            global_selectors = self.datastore.data[\"settings\"][\"application\"].get(\"global_subtractive_selectors\", [])\n            self._subtractive_selectors_cache = [*tag_selectors, *watch_selectors, *global_selectors]\n        return self._subtractive_selectors_cache\n\n    @property\n    def extract_text(self):\n        return self._get_merged_rules('extract_text')\n\n    @property\n    def ignore_text(self):\n        return self._get_merged_rules('ignore_text', include_global=True)\n\n    @property\n    def trigger_text(self):\n        return self._get_merged_rules('trigger_text')\n\n    @property\n    def text_should_not_be_present(self):\n        return self._get_merged_rules('text_should_not_be_present')\n\n    @property\n    def has_include_filters(self):\n        return bool(self.include_filters) and bool(self.include_filters[0].strip())\n\n    @property\n    def has_include_json_filters(self):\n        return any(f.strip().startswith(prefix) for f in self.include_filters for prefix in JSON_FILTER_PREFIXES)\n\n    @property\n    def has_subtractive_selectors(self):\n        return bool(self.subtractive_selectors) and bool(self.subtractive_selectors[0].strip())\n\n\nclass ContentTransformer:\n    \"\"\"Handles text transformations like trimming, sorting, and deduplication.\"\"\"\n\n    @staticmethod\n    def trim_whitespace(text):\n        \"\"\"Remove leading/trailing whitespace from each line.\"\"\"\n        # Use generator expression to avoid building intermediate list\n        return '\\n'.join(line.strip() for line in text.replace(\"\\n\\n\", \"\\n\").splitlines())\n\n    @staticmethod\n    def remove_duplicate_lines(text):\n        \"\"\"Remove duplicate lines while preserving order.\"\"\"\n        return '\\n'.join(dict.fromkeys(line for line in text.replace(\"\\n\\n\", \"\\n\").splitlines()))\n\n    @staticmethod\n    def sort_alphabetically(text):\n        \"\"\"Sort lines alphabetically (case-insensitive).\"\"\"\n        # Remove double line feeds before sorting\n        text = text.replace(\"\\n\\n\", \"\\n\")\n        return '\\n'.join(sorted(text.splitlines(), key=lambda x: x.lower()))\n\n    @staticmethod\n    def extract_by_regex(text, regex_patterns):\n        \"\"\"Extract text matching regex patterns.\"\"\"\n        # Use list of strings instead of concatenating lists repeatedly (avoids O(n²) behavior)\n        regex_matched_output = []\n\n        for s_re in regex_patterns:\n            # Check if it's perl-style regex /.../\n            if re.search(PERL_STYLE_REGEX, s_re, re.IGNORECASE):\n                regex = html_tools.perl_style_slash_enclosed_regex_to_options(s_re)\n                result = re.findall(regex, text)\n\n                for match in result:\n                    if type(match) is tuple:\n                        regex_matched_output.extend(match)\n                        regex_matched_output.append('\\n')\n                    else:\n                        regex_matched_output.append(match)\n                        regex_matched_output.append('\\n')\n            else:\n                # Plain text search (case-insensitive)\n                r = re.compile(re.escape(s_re), re.IGNORECASE)\n                res = r.findall(text)\n                if res:\n                    for match in res:\n                        regex_matched_output.append(match)\n                        regex_matched_output.append('\\n')\n\n        return ''.join(regex_matched_output) if regex_matched_output else ''\n\n\nclass RuleEngine:\n    \"\"\"Evaluates blocking rules (triggers, conditions, text_should_not_be_present).\"\"\"\n\n    @staticmethod\n    def evaluate_trigger_text(content, trigger_patterns):\n        \"\"\"\n        Check if trigger text is present. If trigger_text is configured,\n        content is blocked UNLESS the trigger is found.\n        Returns True if blocked, False if allowed.\n        \"\"\"\n        if not trigger_patterns:\n            return False\n\n        # Assume blocked if trigger_text is configured\n        result = html_tools.strip_ignore_text(\n            content=str(content),\n            wordlist=trigger_patterns,\n            mode=\"line numbers\"\n        )\n        # Unblock if trigger was found\n        return not bool(result)\n\n    @staticmethod\n    def evaluate_text_should_not_be_present(content, patterns):\n        \"\"\"\n        Check if forbidden text is present. If found, block the change.\n        Returns True if blocked, False if allowed.\n        \"\"\"\n        if not patterns:\n            return False\n\n        result = html_tools.strip_ignore_text(\n            content=str(content),\n            wordlist=patterns,\n            mode=\"line numbers\"\n        )\n        # Block if forbidden text was found\n        return bool(result)\n\n    @staticmethod\n    def evaluate_conditions(watch, datastore, content):\n        \"\"\"\n        Evaluate custom conditions ruleset.\n        Returns True if blocked, False if allowed.\n        \"\"\"\n        if not watch.get('conditions') or not watch.get('conditions_match_logic'):\n            return False\n\n        conditions_result = execute_ruleset_against_all_plugins(\n            current_watch_uuid=watch.get('uuid'),\n            application_datastruct=datastore.data,\n            ephemeral_data={'text': content}\n        )\n\n        # Block if conditions not met\n        return not conditions_result.get('result')\n\n\nclass ContentProcessor:\n    \"\"\"Handles content preprocessing, filtering, and extraction.\"\"\"\n\n    def __init__(self, fetcher, watch, filter_config, datastore):\n        self.fetcher = fetcher\n        self.watch = watch\n        self.filter_config = filter_config\n        self.datastore = datastore\n\n    def preprocess_rss(self, content):\n        \"\"\"\n        Convert CDATA/comments in RSS to usable text.\n\n        Supports two RSS processing modes:\n        - 'default': Inline CDATA replacement (original behavior)\n        - 'formatted': Format RSS items with title, link, guid, pubDate, and description (CDATA unmarked)\n        \"\"\"\n        from changedetectionio import rss_tools\n        rss_mode = self.datastore.data[\"settings\"][\"application\"].get(\"rss_reader_mode\")\n        if rss_mode:\n            # Format RSS items nicely with CDATA content unmarked and converted to text\n            return rss_tools.format_rss_items(content)\n        else:\n            # Default: Original inline CDATA replacement\n            return cdata_in_document_to_text(html_content=content)\n\n    def preprocess_pdf(self, raw_content):\n        \"\"\"Convert PDF to HTML using external tool.\"\"\"\n        from shutil import which\n        tool = os.getenv(\"PDF_TO_HTML_TOOL\", \"pdftohtml\")\n        if not which(tool):\n            raise PDFToHTMLToolNotFound(\n                f\"Command-line `{tool}` tool was not found in system PATH, was it installed?\"\n            )\n\n        import subprocess\n        proc = subprocess.Popen(\n            [tool, '-stdout', '-', '-s', 'out.pdf', '-i'],\n            stdout=subprocess.PIPE,\n            stdin=subprocess.PIPE\n        )\n        proc.stdin.write(raw_content)\n        proc.stdin.close()\n        html_content = proc.stdout.read().decode('utf-8')\n        proc.wait(timeout=60)\n\n        # Add metadata for change detection\n        metadata = (\n            f\"<p>Added by changedetection.io: Document checksum - \"\n            f\"{hashlib.md5(raw_content).hexdigest().upper()} \"\n            f\"Original file size - {len(raw_content)} bytes</p>\"\n        )\n        return html_content.replace('</body>', metadata + '</body>')\n\n    def preprocess_json(self, raw_content):\n        \"\"\"Format and sort JSON content.\"\"\"\n        # Then we re-format it, else it does have filters (later on) which will reformat it anyway\n        content = html_tools.extract_json_as_string(content=raw_content, json_filter=\"json:$\")\n\n        # Sort JSON to avoid false alerts from reordering\n        try:\n            content = json.dumps(json.loads(content), sort_keys=True, indent=2, ensure_ascii=False)\n        except Exception:\n            # Might be malformed JSON, continue anyway\n            pass\n\n        return content\n\n    def apply_include_filters(self, content, stream_content_type):\n        \"\"\"Apply CSS, XPath, or JSON filters to extract specific content.\"\"\"\n        filtered_content = \"\"\n\n        for filter_rule in self.filter_config.include_filters:\n            # XPath filters\n            if filter_rule[0] == '/' or filter_rule.startswith('xpath:'):\n                filtered_content += html_tools.xpath_filter(\n                    xpath_filter=filter_rule.replace('xpath:', ''),\n                    html_content=content,\n                    append_pretty_line_formatting=not self.watch.is_source_type_url,\n                    is_xml=stream_content_type.is_rss or stream_content_type.is_xml\n                )\n\n            # XPath1 filters (first match only)\n            elif filter_rule.startswith('xpath1:'):\n                filtered_content += html_tools.xpath1_filter(\n                    xpath_filter=filter_rule.replace('xpath1:', ''),\n                    html_content=content,\n                    append_pretty_line_formatting=not self.watch.is_source_type_url,\n                    is_xml=stream_content_type.is_rss or stream_content_type.is_xml\n                )\n\n            # JSON filters\n            elif any(filter_rule.startswith(prefix) for prefix in JSON_FILTER_PREFIXES):\n                filtered_content += html_tools.extract_json_as_string(\n                    content=content,\n                    json_filter=filter_rule\n                )\n\n            # CSS selectors, default fallback\n            else:\n                filtered_content += html_tools.include_filters(\n                    include_filters=filter_rule,\n                    html_content=content,\n                    append_pretty_line_formatting=not self.watch.is_source_type_url\n                )\n\n        # Raise error if filter returned nothing\n        if not filtered_content.strip():\n            raise FilterNotFoundInResponse(\n                msg=self.filter_config.include_filters,\n                screenshot=self.fetcher.screenshot,\n                xpath_data=self.fetcher.xpath_data\n            )\n\n        return filtered_content\n\n    def apply_subtractive_selectors(self, content):\n        \"\"\"Remove elements matching subtractive selectors.\"\"\"\n        return html_tools.element_removal(self.filter_config.subtractive_selectors, content)\n\n    def extract_text_from_html(self, html_content, stream_content_type):\n        \"\"\"Convert HTML to plain text.\"\"\"\n        do_anchor = self.datastore.data[\"settings\"][\"application\"].get(\"render_anchor_tag_content\", False)\n\n        return html_tools.html_to_text(\n            html_content=html_content,\n            render_anchor_tag_content=do_anchor,\n            is_rss=stream_content_type.is_rss\n        )\n\n\nclass ChecksumCalculator:\n    \"\"\"Calculates checksums with various options.\"\"\"\n\n    @staticmethod\n    def calculate(text, ignore_whitespace=False):\n        \"\"\"Calculate MD5 checksum of text content.\"\"\"\n        if ignore_whitespace:\n            text = text.translate(TRANSLATE_WHITESPACE_TABLE)\n        return hashlib.md5(text.encode('utf-8')).hexdigest()\n\n\n# Some common stuff here that can be moved to a base class\n# (set_proxy_from_list)\nclass perform_site_check(difference_detection_processor):\n\n    def run_changedetection(self, watch, force_reprocess=False):\n        changed_detected = False\n\n        if not watch:\n            raise Exception(\"Watch no longer exists.\")\n\n        current_raw_document_checksum = self.get_raw_document_checksum()\n        # Skip processing only if BOTH conditions are true:\n        # 1. HTML content unchanged (checksum matches last saved checksum)\n        # 2. Watch configuration was not edited (including trigger_text, filters, etc.)\n        # The was_edited flag handles all watch configuration changes, so we don't need\n        # separate checks for trigger_text or other processing rules.\n        if (not force_reprocess and\n            not watch.was_edited and\n            self.last_raw_content_checksum and\n            self.last_raw_content_checksum == current_raw_document_checksum):\n            raise checksumFromPreviousCheckWasTheSame()\n\n        # Initialize components\n        filter_config = FilterConfig(watch, self.datastore)\n        content_processor = ContentProcessor(self.fetcher, watch, filter_config, self.datastore)\n        transformer = ContentTransformer()\n        rule_engine = RuleEngine()\n\n        # Get content type and stream info\n        ctype_header = self.fetcher.get_all_headers().get('content-type', DEFAULT_WHEN_NO_CONTENT_TYPE_HEADER).lower()\n        stream_content_type = guess_stream_type(http_content_header=ctype_header, content=self.fetcher.content)\n\n        # Unset any existing notification error\n        update_obj = {'last_notification_error': False, 'last_error': False}\n        url = watch.link\n\n        self.screenshot = self.fetcher.screenshot\n        self.xpath_data = self.fetcher.xpath_data\n\n        # Track the content type (readonly field, doesn't trigger was_edited)\n        update_obj['content-type'] = ctype_header  # Use hyphen (matches OpenAPI spec and watch_base default)\n\n        # Save the raw content checksum to file (processor implementation detail, not watch config)\n        self.update_last_raw_content_checksum(current_raw_document_checksum)\n\n        # === CONTENT PREPROCESSING ===\n        # Avoid creating unnecessary intermediate string copies by reassigning only when needed\n        content = self.fetcher.content\n\n        # RSS preprocessing\n        if stream_content_type.is_rss:\n            content = content_processor.preprocess_rss(content)\n            if self.datastore.data[\"settings\"][\"application\"].get(\"rss_reader_mode\"):\n                # Now just becomes regular HTML that can have xpath/CSS applied (first of the set etc)\n                stream_content_type.is_rss = False\n                stream_content_type.is_html = True\n                self.fetcher.content = content\n\n        # PDF preprocessing\n        if watch.is_pdf or stream_content_type.is_pdf:\n            content = content_processor.preprocess_pdf(raw_content=self.fetcher.raw_content)\n            stream_content_type.is_html = True\n\n        # JSON - Always reformat it nicely for consistency.\n\n        if stream_content_type.is_json:\n            if not filter_config.has_include_json_filters:\n                content = content_processor.preprocess_json(raw_content=content)\n        #else, otherwise it gets sorted/formatted in the filter stage anyway\n\n        # HTML obfuscation workarounds\n        if stream_content_type.is_html:\n            content = html_tools.workarounds_for_obfuscations(content)\n\n        # Check for LD+JSON price data (for HTML content)\n        if stream_content_type.is_html:\n            update_obj['has_ldjson_price_data'] = html_tools.has_ldjson_product_info(content)\n\n        # === FILTER APPLICATION ===\n        # Start with content reference, avoid copy until modification\n        html_content = content\n\n        # Apply include filters (CSS, XPath, JSON)\n        # Except for plaintext (incase they tried to confuse the system, it will HTML escape\n        #if not stream_content_type.is_plaintext:\n        if filter_config.has_include_filters:\n            html_content = content_processor.apply_include_filters(content, stream_content_type)\n\n        # Apply subtractive selectors\n        if filter_config.has_subtractive_selectors:\n            html_content = content_processor.apply_subtractive_selectors(html_content)\n\n        # === TEXT EXTRACTION ===\n        if watch.is_source_type_url:\n            # For source URLs, keep raw content\n            stripped_text = html_content\n        elif stream_content_type.is_plaintext:\n            # For plaintext, keep as-is without HTML-to-text conversion\n            stripped_text = html_content\n        else:\n            # Extract text from HTML/RSS content (not generic XML)\n            if stream_content_type.is_html or stream_content_type.is_rss:\n                stripped_text = content_processor.extract_text_from_html(html_content, stream_content_type)\n            else:\n                stripped_text = html_content\n\n        # === TEXT TRANSFORMATIONS ===\n        if watch.get('trim_text_whitespace'):\n            stripped_text = transformer.trim_whitespace(stripped_text)\n\n        # Save text before ignore filters (for diff calculation)\n        text_content_before_ignored_filter = stripped_text\n\n        # === DIFF FILTERING ===\n        # If user wants specific diff types (added/removed/replaced only)\n        if watch.has_special_diff_filter_options_set() and len(watch.history.keys()):\n            stripped_text = self._apply_diff_filtering(watch, stripped_text, text_content_before_ignored_filter)\n            if stripped_text is None:\n                # No differences found, but content exists\n                c = ChecksumCalculator.calculate(text_content_before_ignored_filter, ignore_whitespace=True)\n                return False, {'previous_md5': c}, text_content_before_ignored_filter.encode('utf-8')\n\n\n        # === EMPTY PAGE CHECK ===\n        empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False)\n        if not stream_content_type.is_json and not empty_pages_are_a_change and len(stripped_text.strip()) == 0:\n            raise content_fetchers.exceptions.ReplyWithContentButNoText(\n                url=url,\n                status_code=self.fetcher.get_last_status_code(),\n                screenshot=self.fetcher.screenshot,\n                has_filters=filter_config.has_include_filters,\n                html_content=html_content,\n                xpath_data=self.fetcher.xpath_data\n            )\n\n        update_obj[\"last_check_status\"] = self.fetcher.get_last_status_code()\n\n        # === REGEX EXTRACTION ===\n        if filter_config.extract_text:\n            extracted = transformer.extract_by_regex(stripped_text, filter_config.extract_text)\n            stripped_text = extracted\n\n        # === MORE TEXT TRANSFORMATIONS ===\n        if watch.get('remove_duplicate_lines'):\n            stripped_text = transformer.remove_duplicate_lines(stripped_text)\n\n        if watch.get('sort_text_alphabetically'):\n            stripped_text = transformer.sort_alphabetically(stripped_text)\n\n        # === CHECKSUM CALCULATION ===\n        text_for_checksuming = stripped_text\n\n        # Apply ignore_text for checksum calculation\n        if filter_config.ignore_text:\n            text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)\n\n            # Optionally remove ignored lines from output\n            strip_ignored_lines = watch.get('strip_ignored_lines')\n            if strip_ignored_lines is None:\n                strip_ignored_lines = self.datastore.data['settings']['application'].get('strip_ignored_lines')\n            if strip_ignored_lines:\n                stripped_text = text_for_checksuming\n\n        # Calculate checksum\n        ignore_whitespace = self.datastore.data['settings']['application'].get('ignore_whitespace', False)\n        fetched_md5 = ChecksumCalculator.calculate(text_for_checksuming, ignore_whitespace=ignore_whitespace)\n\n        # === BLOCKING RULES EVALUATION ===\n        blocked = False\n\n        # Check trigger_text\n        if rule_engine.evaluate_trigger_text(stripped_text, filter_config.trigger_text):\n            blocked = True\n\n        # Check text_should_not_be_present\n        if rule_engine.evaluate_text_should_not_be_present(stripped_text, filter_config.text_should_not_be_present):\n            blocked = True\n\n        # Check custom conditions\n        if rule_engine.evaluate_conditions(watch, self.datastore, stripped_text):\n            blocked = True\n\n        # === CHANGE DETECTION ===\n        if blocked:\n            changed_detected = False\n        else:\n            # Compare checksums\n            if watch.get('previous_md5') != fetched_md5:\n                changed_detected = True\n\n            # Always record the new checksum\n            update_obj[\"previous_md5\"] = fetched_md5\n\n            # On first run, initialize previous_md5\n            if not watch.get('previous_md5'):\n                watch['previous_md5'] = fetched_md5\n\n        logger.debug(f\"Watch UUID {watch.get('uuid')} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}\")\n\n        # === UNIQUE LINES CHECK ===\n        if changed_detected and watch.get('check_unique_lines', False):\n            has_unique_lines = watch.lines_contain_something_unique_compared_to_history(\n                lines=stripped_text.splitlines(),\n                ignore_whitespace=ignore_whitespace\n            )\n\n            if not has_unique_lines:\n                logger.debug(f\"check_unique_lines: UUID {watch.get('uuid')} didnt have anything new setting change_detected=False\")\n                changed_detected = False\n            else:\n                logger.debug(f\"check_unique_lines: UUID {watch.get('uuid')} had unique content\")\n\n        # Note: Explicit cleanup is only needed here because text_json_diff handles\n        # large strings (100KB-300KB for RSS/HTML). The other processors work with\n        # small strings and don't need this.\n        #\n        # Python would clean these up automatically, but explicit `del` frees memory\n        # immediately rather than waiting for function return, reducing peak memory usage.\n        del content\n        if 'html_content' in locals() and html_content is not stripped_text:\n            del html_content\n        if 'text_content_before_ignored_filter' in locals() and text_content_before_ignored_filter is not stripped_text:\n            del text_content_before_ignored_filter\n        if 'text_for_checksuming' in locals() and text_for_checksuming is not stripped_text:\n            del text_for_checksuming\n\n        return changed_detected, update_obj, stripped_text\n\n    def _apply_diff_filtering(self, watch, stripped_text, text_before_filter):\n        \"\"\"Apply user's diff filtering preferences (show only added/removed/replaced lines).\"\"\"\n        from changedetectionio import diff\n\n        rendered_diff = diff.render_diff(\n            previous_version_file_contents=watch.get_last_fetched_text_before_filters(),\n            newest_version_file_contents=stripped_text,\n            include_equal=False,\n            include_added=watch.get('filter_text_added', True),\n            include_removed=watch.get('filter_text_removed', True),\n            include_replaced=watch.get('filter_text_replaced', True),\n            include_change_type_prefix=False\n        )\n\n        watch.save_last_text_fetched_before_filters(text_before_filter.encode('utf-8'))\n\n        if not rendered_diff and stripped_text:\n            # No differences found\n            return None\n\n        return rendered_diff\n"
  },
  {
    "path": "changedetectionio/pytest.ini",
    "content": "[pytest]\naddopts = --no-start-live-server --live-server-port=0\n#testpaths = tests pytest_invenio\n#live_server_scope = function\n\nfilterwarnings =\n    ignore::DeprecationWarning:urllib3.*:\n\n; logging options\nlog_cli = 1\nlog_cli_level = DEBUG\nlog_cli_format = %(asctime)s %(name)s: %(levelname)s %(message)s"
  },
  {
    "path": "changedetectionio/queue_handlers.py",
    "content": "from blinker import signal\nfrom loguru import logger\nfrom typing import Dict, List, Any, Optional\nimport heapq\nimport queue\nimport threading\n\n# Janus is no longer required - we use pure threading.Queue for multi-loop support\n# try:\n#     import janus\n# except ImportError:\n#     pass  # Not needed anymore\n\n\nclass RecheckPriorityQueue:\n    \"\"\"\n    Thread-safe priority queue supporting multiple async event loops.\n\n    ARCHITECTURE:\n    - Multiple async workers, each with its own event loop in its own thread\n    - Hybrid sync/async design for maximum scalability\n    - Sync interface for ticker thread (threading.Queue)\n    - Async interface for workers (asyncio.Event - NO executor threads!)\n\n    SCALABILITY:\n    - Scales to 100-200+ workers without executor thread exhaustion\n    - Async workers wait on asyncio.Event (pure coroutines, no threads)\n    - Sync callers use threading.Queue (backward compatible)\n\n    WHY NOT JANUS:\n    - Janus binds to ONE event loop at creation time\n    - Our architecture has 15+ workers, each with separate event loops\n    - Workers in different threads/loops cannot share janus async interface\n\n    WHY NOT RUN_IN_EXECUTOR:\n    - With 200 workers, run_in_executor() would block 200 threads\n    - Exhausts ThreadPoolExecutor, starves Flask HTTP handlers\n    - Pure async approach uses 0 threads while waiting\n    \"\"\"\n\n    def __init__(self, maxsize: int = 0):\n        try:\n            import asyncio\n\n            # Sync interface: threading.Queue for ticker thread and Flask routes\n            self._notification_queue = queue.Queue(maxsize=maxsize if maxsize > 0 else 0)\n\n            # Priority storage - thread-safe\n            self._priority_items = []\n            self._lock = threading.RLock()\n\n            # No event signaling needed - pure polling approach\n            # Workers check queue every 50ms (latency acceptable: 0-500ms)\n            # Scales to 1000+ workers: each sleeping worker = ~4KB coroutine, not thread\n\n            # Signals for UI updates\n            self.queue_length_signal = signal('queue_length')\n\n            logger.debug(\"RecheckPriorityQueue initialized successfully\")\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to initialize RecheckPriorityQueue: {str(e)}\")\n            raise\n    \n    # SYNC INTERFACE (for ticker thread)\n    def put(self, item, block: bool = True, timeout: Optional[float] = None):\n        \"\"\"Thread-safe sync put with priority ordering\"\"\"\n        logger.trace(f\"RecheckQueue.put() called for item: {self._get_item_uuid(item)}, block={block}, timeout={timeout}\")\n        try:\n            # CRITICAL: Add to both priority storage AND notification queue atomically\n            # to prevent desynchronization where item exists but no notification\n            with self._lock:\n                heapq.heappush(self._priority_items, item)\n\n                # Add notification - use blocking with timeout for safety\n                # Notification queue is unlimited size, so should never block in practice\n                # but timeout ensures we detect any unexpected issues (deadlock, etc)\n                try:\n                    self._notification_queue.put(True, block=True, timeout=5.0)\n                except Exception as notif_e:\n                    # Notification failed - MUST remove from priority_items to keep in sync\n                    # This prevents \"Priority queue inconsistency\" errors in get()\n                    logger.critical(f\"CRITICAL: Notification queue put failed, removing from priority_items: {notif_e}\")\n                    self._priority_items.remove(item)\n                    heapq.heapify(self._priority_items)\n                    raise  # Re-raise to be caught by outer exception handler\n\n            # Signal emission after successful queue - log but don't fail the operation\n            # Item is already safely queued, so signal failure shouldn't affect queue state\n            try:\n                self._emit_put_signals(item)\n            except Exception as signal_e:\n                logger.error(f\"Failed to emit put signals but item queued successfully: {signal_e}\")\n\n            logger.trace(f\"Successfully queued item: {self._get_item_uuid(item)}\")\n            return True\n\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to put item {self._get_item_uuid(item)}: {type(e).__name__}: {str(e)}\")\n            # Item should have been cleaned up in the inner try/except if notification failed\n            return False\n    \n    def get(self, block: bool = True, timeout: Optional[float] = None):\n        \"\"\"Thread-safe sync get with priority ordering\"\"\"\n        logger.trace(f\"RecheckQueue.get() called, block={block}, timeout={timeout}\")\n        import queue as queue_module\n        try:\n            # Wait for notification (this doesn't return the actual item, just signals availability)\n            self._notification_queue.get(block=block, timeout=timeout)\n\n            # Get highest priority item\n            with self._lock:\n                if not self._priority_items:\n                    logger.critical(f\"CRITICAL: Queue notification received but no priority items available\")\n                    raise Exception(\"Priority queue inconsistency\")\n                item = heapq.heappop(self._priority_items)\n\n            # Signal emission after successful retrieval - log but don't lose the item\n            # Item is already retrieved, so signal failure shouldn't affect queue state\n            try:\n                self._emit_get_signals()\n            except Exception as signal_e:\n                logger.error(f\"Failed to emit get signals but item retrieved successfully: {signal_e}\")\n\n            logger.trace(f\"RecheckQueue.get() successfully retrieved item: {self._get_item_uuid(item)}\")\n            return item\n\n        except queue_module.Empty:\n            # Queue is empty with timeout - expected behavior\n            logger.trace(f\"RecheckQueue.get() timed out - queue is empty (timeout={timeout})\")\n            raise  # noqa\n        except Exception as e:\n            # Re-raise without logging - caller (worker) will handle and log appropriately\n            logger.trace(f\"RecheckQueue.get() failed with exception: {type(e).__name__}: {str(e)}\")\n            raise\n    \n    # ASYNC INTERFACE (for workers)\n    async def async_put(self, item, executor=None):\n        \"\"\"Async put with priority ordering - uses thread pool to avoid blocking\n\n        Args:\n            item: Item to add to queue\n            executor: Optional ThreadPoolExecutor. If None, uses default pool.\n        \"\"\"\n        logger.trace(f\"RecheckQueue.async_put() called for item: {self._get_item_uuid(item)}, executor={executor}\")\n        import asyncio\n        try:\n            # Use run_in_executor to call sync put without blocking event loop\n            loop = asyncio.get_event_loop()\n            result = await loop.run_in_executor(\n                executor,  # Use provided executor or default\n                lambda: self.put(item, block=True, timeout=5.0)\n            )\n\n            logger.trace(f\"RecheckQueue.async_put() successfully queued item: {self._get_item_uuid(item)}\")\n            return result\n\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to async put item {self._get_item_uuid(item)}: {str(e)}\")\n            return False\n\n    async def async_get(self, executor=None, timeout=1.0):\n        \"\"\"\n        Efficient async get using executor for blocking call.\n\n        HYBRID APPROACH: Best of both worlds\n        - Uses run_in_executor for efficient blocking (no polling overhead)\n        - Single timeout (no double-timeout race condition)\n        - Scales well: executor sized to match worker count\n\n        With FETCH_WORKERS=10: 10 threads blocked max (acceptable)\n        With FETCH_WORKERS=200: Need executor with 200+ threads (see worker_pool.py)\n\n        Args:\n            executor: ThreadPoolExecutor (sized to match worker count)\n            timeout: Maximum time to wait in seconds\n\n        Returns:\n            Item from queue\n\n        Raises:\n            queue.Empty: If timeout expires with no item available\n        \"\"\"\n        logger.trace(f\"RecheckQueue.async_get() called, timeout={timeout}\")\n        import asyncio\n        try:\n            # Use run_in_executor to call sync get efficiently\n            # No outer asyncio.wait_for wrapper = no double timeout issue!\n            loop = asyncio.get_event_loop()\n            item = await loop.run_in_executor(\n                executor,\n                lambda: self.get(block=True, timeout=timeout)\n            )\n\n            logger.trace(f\"RecheckQueue.async_get() successfully retrieved item: {self._get_item_uuid(item)}\")\n            return item\n\n        except queue.Empty:\n            logger.trace(f\"RecheckQueue.async_get() timed out - queue is empty\")\n            raise\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to async get item from queue: {type(e).__name__}: {str(e)}\")\n            raise\n    \n    # UTILITY METHODS\n    def qsize(self) -> int:\n        \"\"\"Get current queue size\"\"\"\n        try:\n            with self._lock:\n                return len(self._priority_items)\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to get queue size: {str(e)}\")\n            return 0\n    \n    def empty(self) -> bool:\n        \"\"\"Check if queue is empty\"\"\"\n        return self.qsize() == 0\n\n    def get_queued_uuids(self) -> list:\n        \"\"\"Get list of all queued UUIDs efficiently with single lock\"\"\"\n        try:\n            with self._lock:\n                return [item.item['uuid'] for item in self._priority_items if hasattr(item, 'item') and 'uuid' in item.item]\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to get queued UUIDs: {str(e)}\")\n            return []\n\n    def clear(self):\n        \"\"\"Clear all items from both priority storage and notification queue\"\"\"\n        try:\n            with self._lock:\n                # Clear priority items\n                self._priority_items.clear()\n\n                # Drain all notifications to prevent stale notifications\n                # This is critical for test cleanup to prevent queue desynchronization\n                drained = 0\n                while not self._notification_queue.empty():\n                    try:\n                        self._notification_queue.get_nowait()\n                        drained += 1\n                    except queue.Empty:\n                        break\n\n                if drained > 0:\n                    logger.debug(f\"Cleared queue: removed {drained} notifications\")\n\n            return True\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to clear queue: {str(e)}\")\n            return False\n\n    def close(self):\n        \"\"\"Close the queue\"\"\"\n        try:\n            # Nothing to close for threading.Queue\n            logger.debug(\"RecheckPriorityQueue closed successfully\")\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to close RecheckPriorityQueue: {str(e)}\")\n    \n    # COMPATIBILITY METHODS (from original implementation)\n    @property\n    def queue(self):\n        \"\"\"Provide compatibility with original queue access\"\"\"\n        try:\n            with self._lock:\n                return list(self._priority_items)\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to get queue list: {str(e)}\")\n            return []\n    \n    def get_uuid_position(self, target_uuid: str) -> Dict[str, Any]:\n        \"\"\"Find position of UUID in queue\"\"\"\n        try:\n            with self._lock:\n                queue_list = list(self._priority_items)\n                total_items = len(queue_list)\n                \n                if total_items == 0:\n                    return {'position': None, 'total_items': 0, 'priority': None, 'found': False}\n                \n                # Find target item\n                for item in queue_list:\n                    if (hasattr(item, 'item') and isinstance(item.item, dict) and \n                        item.item.get('uuid') == target_uuid):\n                        \n                        # Count items with higher priority\n                        position = sum(1 for other in queue_list if other.priority < item.priority)\n                        return {\n                            'position': position,\n                            'total_items': total_items, \n                            'priority': item.priority,\n                            'found': True\n                        }\n                \n                return {'position': None, 'total_items': total_items, 'priority': None, 'found': False}\n                \n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to get UUID position for {target_uuid}: {str(e)}\")\n            return {'position': None, 'total_items': 0, 'priority': None, 'found': False}\n    \n    def get_all_queued_uuids(self, limit: Optional[int] = None, offset: int = 0) -> Dict[str, Any]:\n        \"\"\"Get all queued UUIDs with pagination\"\"\"\n        try:\n            with self._lock:\n                queue_list = sorted(self._priority_items)  # Sort by priority\n                total_items = len(queue_list)\n                \n                if total_items == 0:\n                    return {'items': [], 'total_items': 0, 'returned_items': 0, 'has_more': False}\n                \n                # Apply pagination\n                end_idx = min(offset + limit, total_items) if limit else total_items\n                items_to_process = queue_list[offset:end_idx]\n                \n                result = []\n                for position, item in enumerate(items_to_process, start=offset):\n                    if (hasattr(item, 'item') and isinstance(item.item, dict) and \n                        'uuid' in item.item):\n                        result.append({\n                            'uuid': item.item['uuid'],\n                            'position': position,\n                            'priority': item.priority\n                        })\n                \n                return {\n                    'items': result,\n                    'total_items': total_items,\n                    'returned_items': len(result),\n                    'has_more': (offset + len(result)) < total_items\n                }\n                \n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to get all queued UUIDs: {str(e)}\")\n            return {'items': [], 'total_items': 0, 'returned_items': 0, 'has_more': False}\n    \n    def get_queue_summary(self) -> Dict[str, Any]:\n        \"\"\"Get queue summary statistics\"\"\"\n        try:\n            with self._lock:\n                queue_list = list(self._priority_items)\n                total_items = len(queue_list)\n                \n                if total_items == 0:\n                    return {\n                        'total_items': 0, 'priority_breakdown': {},\n                        'immediate_items': 0, 'clone_items': 0, 'scheduled_items': 0\n                    }\n                \n                immediate_items = clone_items = scheduled_items = 0\n                priority_counts = {}\n                \n                for item in queue_list:\n                    priority = item.priority\n                    priority_counts[priority] = priority_counts.get(priority, 0) + 1\n                    \n                    if priority == 1:\n                        immediate_items += 1\n                    elif priority == 5:\n                        clone_items += 1\n                    elif priority > 100:\n                        scheduled_items += 1\n                \n                return {\n                    'total_items': total_items,\n                    'priority_breakdown': priority_counts,\n                    'immediate_items': immediate_items,\n                    'clone_items': clone_items,\n                    'scheduled_items': scheduled_items,\n                    'min_priority': min(priority_counts.keys()) if priority_counts else None,\n                    'max_priority': max(priority_counts.keys()) if priority_counts else None\n                }\n                \n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to get queue summary: {str(e)}\")\n            return {'total_items': 0, 'priority_breakdown': {}, 'immediate_items': 0, \n                   'clone_items': 0, 'scheduled_items': 0}\n    \n    # PRIVATE METHODS\n    def _get_item_uuid(self, item) -> str:\n        \"\"\"Safely extract UUID from item for logging\"\"\"\n        try:\n            if hasattr(item, 'item') and isinstance(item.item, dict):\n                return item.item.get('uuid', 'unknown')\n        except Exception:\n            pass\n        return 'unknown'\n\n    def _emit_put_signals(self, item):\n        \"\"\"Emit signals when item is added\"\"\"\n        try:\n            # Watch update signal\n            if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:\n                watch_check_update = signal('watch_check_update')\n                if watch_check_update:\n                    watch_check_update.send(watch_uuid=item.item['uuid'])\n\n            # Queue length signal\n            if self.queue_length_signal:\n                self.queue_length_signal.send(length=self.qsize())\n\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to emit put signals: {str(e)}\")\n\n    def _emit_get_signals(self):\n        \"\"\"Emit signals when item is removed\"\"\"\n        try:\n            if self.queue_length_signal:\n                self.queue_length_signal.send(length=self.qsize())\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to emit get signals: {str(e)}\")\n\n\nclass NotificationQueue:\n    \"\"\"\n    Ultra-reliable notification queue using pure janus.\n    \n    CRITICAL DESIGN NOTE: Both sync_q and async_q are required because:\n    - sync_q: Used by Flask routes, ticker threads, and other synchronous code\n    - async_q: Used by async workers and coroutines\n    \n    DO NOT REMOVE EITHER INTERFACE - they bridge different execution contexts.\n    See RecheckPriorityQueue docstring above for detailed explanation.\n    \n    Simple wrapper around janus with bulletproof error handling.\n    \"\"\"\n    \n    def __init__(self, maxsize: int = 0, datastore=None):\n        try:\n            # Use pure threading.Queue to avoid event loop binding issues\n            self._notification_queue = queue.Queue(maxsize=maxsize if maxsize > 0 else 0)\n            self.notification_event_signal = signal('notification_event')\n            self.datastore = datastore  # For checking all_muted setting\n            self._lock = threading.RLock()\n            logger.debug(\"NotificationQueue initialized successfully\")\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to initialize NotificationQueue: {str(e)}\")\n            raise\n\n    def set_datastore(self, datastore):\n        \"\"\"Set datastore reference after initialization (for circular dependency handling)\"\"\"\n        self.datastore = datastore\n    \n    def put(self, item: Dict[str, Any], block: bool = True, timeout: Optional[float] = None):\n        \"\"\"Thread-safe sync put with signal emission\"\"\"\n        logger.trace(f\"NotificationQueue.put() called for item: {item.get('uuid', 'unknown')}, block={block}, timeout={timeout}\")\n        try:\n            # Check if all notifications are muted\n            if self.datastore and self.datastore.data['settings']['application'].get('all_muted', False):\n                logger.debug(f\"Notification blocked - all notifications are muted: {item.get('uuid', 'unknown')}\")\n                return False\n\n            with self._lock:\n                self._notification_queue.put(item, block=block, timeout=timeout)\n            self._emit_notification_signal(item)\n            logger.trace(f\"NotificationQueue.put() successfully queued notification: {item.get('uuid', 'unknown')}\")\n            return True\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to put notification {item.get('uuid', 'unknown')}: {str(e)}\")\n            return False\n    \n    async def async_put(self, item: Dict[str, Any], executor=None):\n        \"\"\"Async put with signal emission - uses thread pool\n\n        Args:\n            item: Notification item to queue\n            executor: Optional ThreadPoolExecutor\n        \"\"\"\n        logger.trace(f\"NotificationQueue.async_put() called for item: {item.get('uuid', 'unknown')}, executor={executor}\")\n        import asyncio\n        try:\n            # Check if all notifications are muted\n            if self.datastore and self.datastore.data['settings']['application'].get('all_muted', False):\n                logger.debug(f\"Notification blocked - all notifications are muted: {item.get('uuid', 'unknown')}\")\n                return False\n\n            loop = asyncio.get_event_loop()\n            await loop.run_in_executor(executor, lambda: self.put(item, block=True, timeout=5.0))\n            logger.trace(f\"NotificationQueue.async_put() successfully queued notification: {item.get('uuid', 'unknown')}\")\n            return True\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to async put notification {item.get('uuid', 'unknown')}: {str(e)}\")\n            return False\n\n    def get(self, block: bool = True, timeout: Optional[float] = None):\n        \"\"\"Thread-safe sync get\"\"\"\n        logger.trace(f\"NotificationQueue.get() called, block={block}, timeout={timeout}\")\n        try:\n            with self._lock:\n                item = self._notification_queue.get(block=block, timeout=timeout)\n            logger.trace(f\"NotificationQueue.get() retrieved item: {item.get('uuid', 'unknown') if isinstance(item, dict) else 'unknown'}\")\n            return item\n        except queue.Empty as e:\n            logger.trace(f\"NotificationQueue.get() timed out - queue is empty (timeout={timeout})\")\n            raise e\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to get notification: {type(e).__name__}: {str(e)}\")\n            raise e\n\n    async def async_get(self, executor=None):\n        \"\"\"Async get - uses thread pool\n\n        Args:\n            executor: Optional ThreadPoolExecutor\n        \"\"\"\n        logger.trace(f\"NotificationQueue.async_get() called, executor={executor}\")\n        import asyncio\n        try:\n            loop = asyncio.get_event_loop()\n            item = await loop.run_in_executor(executor, lambda: self.get(block=True, timeout=1.0))\n            logger.trace(f\"NotificationQueue.async_get() retrieved item: {item.get('uuid', 'unknown') if isinstance(item, dict) else 'unknown'}\")\n            return item\n        except queue.Empty as e:\n            logger.trace(f\"NotificationQueue.async_get() timed out - queue is empty\")\n            raise e\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to async get notification: {type(e).__name__}: {str(e)}\")\n            raise e\n    \n    def qsize(self) -> int:\n        \"\"\"Get current queue size\"\"\"\n        try:\n            with self._lock:\n                return self._notification_queue.qsize()\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to get notification queue size: {str(e)}\")\n            return 0\n\n    def empty(self) -> bool:\n        \"\"\"Check if queue is empty\"\"\"\n        return self.qsize() == 0\n\n    def close(self):\n        \"\"\"Close the queue\"\"\"\n        try:\n            # Nothing to close for threading.Queue\n            logger.debug(\"NotificationQueue closed successfully\")\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to close NotificationQueue: {str(e)}\")\n    \n    def _emit_notification_signal(self, item: Dict[str, Any]):\n        \"\"\"Emit notification signal\"\"\"\n        try:\n            if self.notification_event_signal and isinstance(item, dict):\n                watch_uuid = item.get('uuid')\n                if watch_uuid:\n                    self.notification_event_signal.send(watch_uuid=watch_uuid)\n                else:\n                    self.notification_event_signal.send()\n        except Exception as e:\n            logger.critical(f\"CRITICAL: Failed to emit notification signal: {str(e)}\")"
  },
  {
    "path": "changedetectionio/queuedWatchMetaData.py",
    "content": "from dataclasses import dataclass, field\nfrom typing import Any\n\n# So that we can queue some metadata in `item`\n# https://docs.python.org/3/library/queue.html#queue.PriorityQueue\n#\n@dataclass(order=True)\nclass PrioritizedItem:\n    priority: int\n    item: Any=field(compare=False)\n"
  },
  {
    "path": "changedetectionio/realtime/README.md",
    "content": "# Real-time Socket.IO Implementation\n\nThis directory contains the Socket.IO implementation for changedetection.io's real-time updates.\n\n## Architecture Overview\n\nThe real-time system provides live updates to the web interface for:\n- Watch status changes (checking, completed, errors)\n- Queue length updates  \n- General statistics updates\n\n## Current Implementation\n\n### Socket.IO Configuration\n- **Async Mode**: `threading` (default) or `gevent` (optional via SOCKETIO_MODE env var)\n- **Server**: Flask-SocketIO with threading support\n- **Background Tasks**: Python threading with daemon threads\n\n### Async Worker Integration\n- **Workers**: Async workers using asyncio for watch processing\n- **Queue**: AsyncSignalPriorityQueue for job distribution\n- **Signals**: Blinker signals for real-time updates between workers and Socket.IO\n\n### Environment Variables\n- `SOCKETIO_MODE=threading` (default, recommended)\n- `SOCKETIO_MODE=gevent` (optional, has cross-platform limitations)\n\n## Architecture Decision: Why Threading Mode?\n\n### Previous Issues with Eventlet\n**Eventlet was completely removed** due to fundamental compatibility issues:\n\n1. **Monkey Patching Conflicts**: `eventlet.monkey_patch()` globally replaced Python's threading/socket modules, causing conflicts with:\n   - Playwright's synchronous browser automation\n   - Async worker event loops\n   - Various Python libraries expecting real threading\n\n2. **Python 3.12+ Compatibility**: Eventlet had issues with newer Python versions and asyncio integration\n\n3. **CVE-2023-29483**: Security vulnerability in eventlet's dnspython dependency\n\n### Current Solution Benefits\n✅ **Threading Mode Advantages**:\n- Full compatibility with async workers and Playwright\n- No monkey patching - uses standard Python threading\n- Better Python 3.12+ support\n- Cross-platform compatibility (Windows, macOS, Linux)\n- No external async library dependencies\n- Fast shutdown capabilities\n\n✅ **Optional Gevent Support**:\n- Available via `SOCKETIO_MODE=gevent` for high-concurrency scenarios\n- Cross-platform limitations documented in requirements.txt\n- Not recommended as default due to Windows socket limits and macOS ARM build issues\n\n## Socket.IO Mode Configuration\n\n### Threading Mode (Default)\n```python\n# Enabled automatically\nasync_mode = 'threading'\nsocketio = SocketIO(app, async_mode='threading')\n```\n\n### Gevent Mode (Optional)\n```bash\n# Set environment variable\nexport SOCKETIO_MODE=gevent\n```\n\n## Background Tasks\n\n### Queue Polling\n- **Threading Mode**: `threading.Thread` with `threading.Event` for shutdown\n- **Signal Handling**: Blinker signals for watch state changes\n- **Real-time Updates**: Direct Socket.IO `emit()` calls to connected clients\n\n### Worker Integration\n- **Async Workers**: Run in separate asyncio event loop thread\n- **Communication**: AsyncSignalPriorityQueue bridges async workers and Socket.IO\n- **Updates**: Real-time updates sent when workers complete tasks\n\n## Files in This Directory\n\n- `socket_server.py`: Main Socket.IO initialization and event handling\n- `events.py`: Watch operation event handlers  \n- `__init__.py`: Module initialization\n\n## Production Deployment\n\n### Recommended WSGI Servers\nFor production with Socket.IO threading mode:\n- **Gunicorn**: `gunicorn --worker-class eventlet changedetection:app` (if using gevent mode)\n- **uWSGI**: With threading support\n- **Docker**: Built-in Flask server works well for containerized deployments\n\n### Performance Considerations\n- Threading mode: Better memory usage, standard Python threading\n- Gevent mode: Higher concurrency but platform limitations\n- Async workers: Separate from Socket.IO, provides scalability\n\n## Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SOCKETIO_MODE` | `threading` | Socket.IO async mode (`threading` or `gevent`) |\n| `FETCH_WORKERS` | `10` | Number of async workers for watch processing |\n| `CHANGEDETECTION_HOST` | `0.0.0.0` | Server bind address |\n| `CHANGEDETECTION_PORT` | `5000` | Server port |\n\n## Debugging Tips\n\n1. **Socket.IO Issues**: Check browser dev tools for WebSocket connection errors\n2. **Threading Issues**: Monitor with `ps -T` to check thread count  \n3. **Worker Issues**: Use `/worker-health` endpoint to check async worker status\n4. **Queue Issues**: Use `/queue-status` endpoint to monitor job queue\n5. **Performance**: Use `/gc-cleanup` endpoint to trigger memory cleanup\n\n## Migration Notes\n\nIf upgrading from eventlet-based versions:\n- Remove any `EVENTLET_*` environment variables\n- No code changes needed - Socket.IO mode is automatically configured\n- Optional: Set `SOCKETIO_MODE=gevent` if high concurrency is required and platform supports it"
  },
  {
    "path": "changedetectionio/realtime/__init__.py",
    "content": "\"\"\"\nSocket.IO realtime updates module for changedetection.io\n\"\"\""
  },
  {
    "path": "changedetectionio/realtime/events.py",
    "content": "from flask_socketio import emit\nfrom loguru import logger\nfrom blinker import signal\n\n\ndef register_watch_operation_handlers(socketio, datastore):\n    \"\"\"Register Socket.IO event handlers for watch operations\"\"\"\n\n    @socketio.on('watch_operation')\n    def handle_watch_operation(data):\n        \"\"\"Handle watch operations like pause, mute, recheck via Socket.IO\"\"\"\n        try:\n            op = data.get('op')\n            uuid = data.get('uuid')\n            \n            logger.debug(f\"Socket.IO: Received watch operation '{op}' for UUID {uuid}\")\n            \n            if not op or not uuid:\n                emit('operation_result', {'success': False, 'error': 'Missing operation or UUID'})\n                return\n            \n            # Check if watch exists\n            if not datastore.data['watching'].get(uuid):\n                emit('operation_result', {'success': False, 'error': 'Watch not found'})\n                return\n            \n            watch = datastore.data['watching'][uuid]\n            \n            # Perform the operation\n            if op == 'pause':\n                watch.toggle_pause()\n                watch.commit()\n                logger.info(f\"Socket.IO: Toggled pause for watch {uuid}\")\n            elif op == 'mute':\n                watch.toggle_mute()\n                watch.commit()\n                logger.info(f\"Socket.IO: Toggled mute for watch {uuid}\")\n            elif op == 'recheck':\n                # Import here to avoid circular imports\n                from changedetectionio.flask_app import update_q\n                from changedetectionio import queuedWatchMetaData\n                from changedetectionio import worker_pool\n                \n                worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))\n                logger.info(f\"Socket.IO: Queued recheck for watch {uuid}\")\n            else:\n                emit('operation_result', {'success': False, 'error': f'Unknown operation: {op}'})\n                return\n            \n            # Send signal to update UI\n            watch_check_update = signal('watch_check_update')\n            if watch_check_update:\n                watch_check_update.send(watch_uuid=uuid)\n            \n            # Send success response to client\n            emit('operation_result', {'success': True, 'operation': op, 'uuid': uuid})\n            \n        except Exception as e:\n            logger.error(f\"Socket.IO error in handle_watch_operation: {str(e)}\")\n            emit('operation_result', {'success': False, 'error': str(e)})\n"
  },
  {
    "path": "changedetectionio/realtime/socket_server.py",
    "content": "import timeago\nfrom flask_socketio import SocketIO\nfrom flask_babel import gettext, get_locale\n\nimport time\nimport os\nfrom loguru import logger\nfrom blinker import signal\n\nfrom changedetectionio import strtobool\nfrom changedetectionio.languages import get_timeago_locale\n\n\nclass SignalHandler:\n    \"\"\"A standalone class to receive signals\"\"\"\n\n    def __init__(self, socketio_instance, datastore):\n        self.socketio_instance = socketio_instance\n        self.datastore = datastore\n\n        # Connect to the watch_check_update signal\n        from changedetectionio.flask_app import watch_check_update as wcc\n        wcc.connect(self.handle_signal, weak=False)\n        #        logger.info(\"SignalHandler: Connected to signal from direct import\")\n\n        # Connect to the queue_length signal\n        queue_length_signal = signal('queue_length')\n        queue_length_signal.connect(self.handle_queue_length, weak=False)\n        #       logger.info(\"SignalHandler: Connected to queue_length signal\")\n\n        watch_delete_signal = signal('watch_deleted')\n        watch_delete_signal.connect(self.handle_deleted_signal, weak=False)\n\n        watch_favicon_bumped_signal = signal('watch_favicon_bump')\n        watch_favicon_bumped_signal.connect(self.handle_watch_bumped_favicon_signal, weak=False)\n\n        watch_small_status_comment_signal = signal('watch_small_status_comment')\n        watch_small_status_comment_signal.connect(self.handle_watch_small_status_update, weak=False)\n\n        # Connect to the notification_event signal\n        notification_event_signal = signal('notification_event')\n        notification_event_signal.connect(self.handle_notification_event, weak=False)\n        logger.info(\"SignalHandler: Connected to notification_event signal\")\n\n\n    def handle_watch_small_status_update(self, *args, **kwargs):\n        \"\"\"Small simple status update, for example 'Connecting...'\"\"\"\n        watch_uuid = kwargs.get('watch_uuid')\n        status = kwargs.get('status')\n\n        if watch_uuid and status:\n            logger.debug(f\"Socket.IO: Received watch small status update '{status}' for UUID {watch_uuid}\")\n            # Emit the status update to all connected clients\n            self.socketio_instance.emit(\"watch_small_status_comment\", {\n                \"uuid\": watch_uuid,\n                \"status\": status,\n                \"event_timestamp\": time.time()\n            })\n\n\n\n    def handle_signal(self, *args, **kwargs):\n        logger.trace(f\"SignalHandler: Signal received with {len(args)} args and {len(kwargs)} kwargs\")\n        # Safely extract the watch UUID from kwargs\n        watch_uuid = kwargs.get('watch_uuid')\n        app_context = kwargs.get('app_context')\n\n        if watch_uuid:\n            # Get the watch object from the datastore\n            watch = self.datastore.data['watching'].get(watch_uuid)\n            if watch:\n                if app_context:\n                    # note\n                    with app_context.app_context():\n                        with app_context.test_request_context():\n                            # Forward to handle_watch_update with the watch parameter\n                            handle_watch_update(self.socketio_instance, watch=watch, datastore=self.datastore)\n                else:\n                    handle_watch_update(self.socketio_instance, watch=watch, datastore=self.datastore)\n\n                logger.trace(f\"Signal handler processed watch UUID {watch_uuid}\")\n            else:\n                logger.warning(f\"Watch UUID {watch_uuid} not found in datastore\")\n\n    def handle_watch_bumped_favicon_signal(self, *args, **kwargs):\n        watch_uuid = kwargs.get('watch_uuid')\n        if watch_uuid:\n            # Emit the queue size to all connected clients\n            self.socketio_instance.emit(\"watch_bumped_favicon\", {\n                \"uuid\": watch_uuid,\n                \"event_timestamp\": time.time()\n            })\n        logger.debug(f\"Watch UUID {watch_uuid} got its favicon updated\")\n\n    def handle_deleted_signal(self, *args, **kwargs):\n        watch_uuid = kwargs.get('watch_uuid')\n        if watch_uuid:\n            # Emit the queue size to all connected clients\n            self.socketio_instance.emit(\"watch_deleted\", {\n                \"uuid\": watch_uuid,\n                \"event_timestamp\": time.time()\n            })\n        logger.debug(f\"Watch UUID {watch_uuid} was deleted\")\n\n    def handle_queue_length(self, *args, **kwargs):\n        \"\"\"Handle queue_length signal and emit to all clients\"\"\"\n        try:\n            queue_length = kwargs.get('length', 0)\n            logger.debug(f\"SignalHandler: Queue length update received: {queue_length}\")\n\n            # Emit the queue size to all connected clients\n            self.socketio_instance.emit(\"queue_size\", {\n                \"q_length\": queue_length,\n                \"event_timestamp\": time.time()\n            })\n\n        except Exception as e:\n            logger.error(f\"Socket.IO error in handle_queue_length: {str(e)}\")\n\n    def handle_notification_event(self, *args, **kwargs):\n        \"\"\"Handle notification_event signal and emit to all clients\"\"\"\n        try:\n            watch_uuid = kwargs.get('watch_uuid')\n            logger.debug(f\"SignalHandler: Notification event received for watch UUID: {watch_uuid}\")\n\n            # Emit the notification event to all connected clients\n            self.socketio_instance.emit(\"notification_event\", {\n                \"watch_uuid\": watch_uuid,\n                \"event_timestamp\": time.time()\n            })\n\n            logger.trace(f\"Socket.IO: Emitted notification_event for watch UUID {watch_uuid}\")\n\n        except Exception as e:\n            logger.error(f\"Socket.IO error in handle_notification_event: {str(e)}\")\n\n\n\ndef handle_watch_update(socketio, **kwargs):\n    \"\"\"Handle watch update signal from blinker\"\"\"\n    try:\n        watch = kwargs.get('watch')\n        datastore = kwargs.get('datastore')\n\n        # Emit the watch update to all connected clients\n        from changedetectionio.flask_app import update_q\n        from changedetectionio.flask_app import _jinja2_filter_datetime\n        from changedetectionio import worker_pool\n\n        # Get list of watches that are currently running\n        running_uuids = worker_pool.get_running_uuids()\n\n        # Get list of watches in the queue (efficient single-lock method)\n        queue_list = update_q.get_queued_uuids()\n\n        # Get the error texts from the watch\n        error_texts = watch.compile_error_texts()\n        # Create a simplified watch data object to send to clients\n\n        watch_data = {\n            'checking_now': True if watch.get('uuid') in running_uuids else False,\n            'error_text': error_texts,\n            'event_timestamp': time.time(),\n            'fetch_time': watch.get('fetch_time'),\n            'has_error': True if error_texts else False,\n            'has_favicon': True if watch.get_favicon_filename() else False,\n            'history_n': watch.history_n,\n            'last_changed_text': timeago.format(int(watch.last_changed), time.time(), get_timeago_locale(str(get_locale()))) if watch.history_n >= 2 and int(watch.last_changed) > 0 else gettext('Not yet'),\n            'last_checked': watch.get('last_checked'),\n            'last_checked_text': _jinja2_filter_datetime(watch),\n            'notification_muted': True if watch.get('notification_muted') else False,\n            'paused': True if watch.get('paused') else False,\n            'queued': True if watch.get('uuid') in queue_list else False,\n            'unviewed': watch.has_unviewed,\n            'uuid': watch.get('uuid'),\n        }\n\n        errored_count = 0\n        for watch_uuid_iter, watch_iter in datastore.data['watching'].items():\n            if watch_iter.get('last_error'):\n                errored_count += 1\n\n        general_stats = {\n            'count_errors': errored_count,\n            'unread_changes_count': datastore.unread_changes_count\n        }\n\n        # Debug what's being emitted\n        # logger.debug(f\"Emitting 'watch_update' event for {watch.get('uuid')}, data: {watch_data}\")\n\n        # Emit to all clients (no 'broadcast' parameter needed - it's the default behavior)\n        socketio.emit(\"watch_update\", {'watch': watch_data})\n        socketio.emit(\"general_stats_update\", general_stats)\n\n        # Log after successful emit - use watch_data['uuid'] to avoid variable shadowing issues\n        logger.trace(f\"Socket.IO: Emitted update for watch {watch_data['uuid']}, Checking now: {watch_data['checking_now']}\")\n\n    except Exception as e:\n        logger.error(f\"Socket.IO error in handle_watch_update: {str(e)}\")\n\n\ndef _suppress_werkzeug_ws_abrupt_disconnect_noise():\n    \"\"\"Patch BaseWSGIServer.log to suppress the AssertionError traceback that fires when\n    a browser closes a WebSocket connection mid-handshake (e.g. closing a tab).\n    The exception is caught inside run_wsgi and routed to self.server.log() — it never\n    propagates out, so wrapping run_wsgi doesn't help. Patching the log method is the\n    only reliable intercept point. The error is cosmetic: Socket.IO already handles the\n    disconnect correctly via its own disconnect handler and timeout logic.\"\"\"\n    try:\n        from werkzeug.serving import BaseWSGIServer\n        _original_log = BaseWSGIServer.log\n\n        def _filtered_log(self, type, message, *args):\n            if type == 'error' and 'write() before start_response' in message:\n                return\n            _original_log(self, type, message, *args)\n\n        BaseWSGIServer.log = _filtered_log\n    except Exception:\n        pass\n\n\ndef init_socketio(app, datastore):\n    \"\"\"Initialize SocketIO with the main Flask app\"\"\"\n    _suppress_werkzeug_ws_abrupt_disconnect_noise()\n\n    import platform\n    import sys\n\n    # Platform-specific async_mode selection for better stability\n    system = platform.system().lower()\n    python_version = sys.version_info\n\n    # Check for SocketIO mode configuration via environment variable\n    # Default is 'threading' for best cross-platform compatibility\n    socketio_mode = os.getenv('SOCKETIO_MODE', 'threading').lower()\n\n    if socketio_mode == 'gevent':\n        # Use gevent mode (higher concurrency but platform limitations)\n        try:\n            import gevent\n            async_mode = 'gevent'\n            logger.info(f\"SOCKETIO_MODE=gevent: Using {async_mode} mode for Socket.IO\")\n        except ImportError:\n            async_mode = 'threading'\n            logger.warning(f\"SOCKETIO_MODE=gevent but gevent not available, falling back to {async_mode} mode\")\n    elif socketio_mode == 'threading':\n        # Use threading mode (default - best compatibility)\n        async_mode = 'threading'\n        logger.info(f\"SOCKETIO_MODE=threading: Using {async_mode} mode for Socket.IO\")\n    else:\n        # Invalid mode specified, use default\n        async_mode = 'threading'\n        logger.warning(f\"Invalid SOCKETIO_MODE='{socketio_mode}', using default {async_mode} mode for Socket.IO\")\n\n    # Log platform info for debugging\n    logger.info(f\"Platform: {system}, Python: {python_version.major}.{python_version.minor}, Socket.IO mode: {async_mode}\")\n\n    # Restrict SocketIO CORS to same origin by default, can be overridden with env var\n    cors_origins = os.environ.get('SOCKETIO_CORS_ORIGINS', None)\n\n    socketio = SocketIO(app,\n                        async_mode=async_mode,\n                        cors_allowed_origins=cors_origins,  # None means same-origin only\n                        logger=strtobool(os.getenv('SOCKETIO_LOGGING', 'False')),\n                        engineio_logger=strtobool(os.getenv('SOCKETIO_LOGGING', 'False')),\n                        # Disable WebSocket compression to prevent memory accumulation\n                        # Flask-Compress already handles HTTP response compression\n                        engineio_options={'http_compression': False, 'compression_threshold': 0})\n\n    # Set up event handlers\n    logger.info(\"Socket.IO: Registering connect event handler\")\n\n    @socketio.on('checkbox-operation')\n    def event_checkbox_operations(data):\n        from changedetectionio.blueprint.ui import _handle_operations\n        from changedetectionio import queuedWatchMetaData\n        from changedetectionio import worker_pool\n        from changedetectionio.flask_app import update_q, watch_check_update\n        import threading\n\n        logger.trace(f\"Got checkbox operations event: {data}\")\n\n        datastore = socketio.datastore\n\n        def run_operation():\n            \"\"\"Run the operation in a background thread to avoid blocking the socket.io event loop\"\"\"\n            try:\n                _handle_operations(\n                    op=data.get('op'),\n                    uuids=data.get('uuids'),\n                    datastore=datastore,\n                    extra_data=data.get('extra_data'),\n                    worker_pool=worker_pool,\n                    update_q=update_q,\n                    queuedWatchMetaData=queuedWatchMetaData,\n                    watch_check_update=watch_check_update,\n                    emit_flash=False\n                )\n            except Exception as e:\n                logger.error(f\"Error in checkbox operation thread: {e}\")\n\n        # Start operation in a disposable daemon thread\n        thread = threading.Thread(target=run_operation, daemon=True, name=f\"checkbox-op-{data.get('op')}\")\n        thread.start()\n\n    @socketio.on('connect')\n    def handle_connect():\n        \"\"\"Handle client connection\"\"\"\n        #        logger.info(\"Socket.IO: CONNECT HANDLER CALLED - Starting connection process\")\n        from flask import request\n        from flask_login import current_user\n        from changedetectionio.flask_app import update_q\n\n        # Access datastore from socketio\n        datastore = socketio.datastore\n        #        logger.info(f\"Socket.IO: Current user authenticated: {current_user.is_authenticated if hasattr(current_user, 'is_authenticated') else 'No current_user'}\")\n\n        # Check if authentication is required and user is not authenticated\n        has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv(\"SALTED_PASS\", False)\n        #        logger.info(f\"Socket.IO: Password enabled: {has_password_enabled}\")\n        if has_password_enabled and not current_user.is_authenticated:\n            logger.warning(\"Socket.IO: Rejecting unauthenticated connection\")\n            return False  # Reject the connection\n\n        # Send the current queue size to the newly connected client\n        try:\n            queue_size = update_q.qsize()\n            socketio.emit(\"queue_size\", {\n                \"q_length\": queue_size,\n                \"event_timestamp\": time.time()\n            }, room=request.sid)  # Send only to this client\n            logger.debug(f\"Socket.IO: Sent initial queue size {queue_size} to new client\")\n        except Exception as e:\n            logger.error(f\"Socket.IO error sending initial queue size: {str(e)}\")\n\n        logger.info(\"Socket.IO: Client connected\")\n\n    #    logger.info(\"Socket.IO: Registering disconnect event handler\")\n    @socketio.on('disconnect')\n    def handle_disconnect():\n        \"\"\"Handle client disconnection\"\"\"\n        logger.info(\"Socket.IO: Client disconnected\")\n\n    # Create a dedicated signal handler that will receive signals and emit them to clients\n    signal_handler = SignalHandler(socketio, datastore)\n\n    # Register watch operation event handlers\n    from .events import register_watch_operation_handlers\n    register_watch_operation_handlers(socketio, datastore)\n\n    # Store the datastore reference on the socketio object for later use\n    socketio.datastore = datastore\n\n    # No stop event needed for threading mode - threads check app.config.exit directly\n\n    # Add a shutdown method to the socketio object\n    def shutdown():\n        \"\"\"Shutdown the SocketIO server fast and aggressively\"\"\"\n        try:\n            logger.info(\"Socket.IO: Fast shutdown initiated...\")\n            logger.info(\"Socket.IO: Fast shutdown complete\")\n        except Exception as e:\n            logger.error(f\"Socket.IO error during shutdown: {str(e)}\")\n\n    # Attach the shutdown method to the socketio object\n    socketio.shutdown = shutdown\n\n    logger.info(\"Socket.IO initialized and attached to main Flask app\")\n    logger.info(f\"Socket.IO: Registered event handlers: {socketio.handlers if hasattr(socketio, 'handlers') else 'No handlers found'}\")\n    return socketio\n"
  },
  {
    "path": "changedetectionio/rss_tools.py",
    "content": "\"\"\"\nRSS/Atom feed processing tools for changedetection.io\n\"\"\"\n\nfrom loguru import logger\nimport re\n\n\ndef cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str:\n    \"\"\"\n    Process CDATA sections in HTML/XML content - inline replacement.\n\n    Args:\n        html_content: The HTML/XML content to process\n        render_anchor_tag_content: Whether to render anchor tag content\n\n    Returns:\n        Processed HTML/XML content with CDATA sections replaced inline\n    \"\"\"\n    from xml.sax.saxutils import escape as xml_escape\n    from .html_tools import html_to_text\n\n    pattern = '<!\\[CDATA\\[(\\s*(?:.(?<!\\]\\]>)\\s*)*)\\]\\]>'\n\n    def repl(m):\n        text = m.group(1)\n        return xml_escape(html_to_text(html_content=text, render_anchor_tag_content=render_anchor_tag_content)).strip()\n\n    return re.sub(pattern, repl, html_content)\n\n\n# Jinja2 template for formatting RSS/Atom feed entries\n# Covers all common feedparser entry fields including namespaced elements\n# Outputs HTML that will be converted to text via html_to_text\n# @todo - This could be a UI setting in the future\nRSS_ENTRY_TEMPLATE = \"\"\"<article class=\"rss-item\" id=\"{{ entry.id|replace('\"', '')|replace(' ', '-') }}\">{%- if entry.title -%}Title: {{ entry.title }}<br>{%- endif -%}\n{%- if entry.link -%}<strong>Link:</strong> <a href=\"{{ entry.link }}\">{{ entry.link }}</a><br>\n{%- endif -%}\n{%- if entry.id -%}\n<strong>Guid:</strong> {{ entry.id }}<br>\n{%- endif -%}\n{%- if entry.published -%}\n<strong>PubDate:</strong> {{ entry.published }}<br>\n{%- endif -%}\n{%- if entry.updated and entry.updated != entry.published -%}\n<strong>Updated:</strong> {{ entry.updated }}<br>\n{%- endif -%}\n{%- if entry.author -%}\n<strong>Author:</strong> {{ entry.author }}<br>\n{%- elif entry.author_detail and entry.author_detail.name -%}\n<strong>Author:</strong> {{ entry.author_detail.name }}\n{%- if entry.author_detail.email %} ({{ entry.author_detail.email }}){% endif -%}\n<br>\n{%- endif -%}\n{%- if entry.contributors -%}\n<strong>Contributors:</strong> {% for contributor in entry.contributors -%}\n{{ contributor.name if contributor.name else contributor }}\n{%- if not loop.last %}, {% endif -%}\n{%- endfor %}<br>\n{%- endif -%}\n{%- if entry.publisher -%}\n<strong>Publisher:</strong> {{ entry.publisher }}<br>\n{%- endif -%}\n{%- if entry.rights -%}\n<strong>Rights:</strong> {{ entry.rights }}<br>\n{%- endif -%}\n{%- if entry.license -%}\n<strong>License:</strong> {{ entry.license }}<br>\n{%- endif -%}\n{%- if entry.language -%}\n<strong>Language:</strong> {{ entry.language }}<br>\n{%- endif -%}\n{%- if entry.tags -%}\n<strong>Tags:</strong> {% for tag in entry.tags -%}\n{{ tag.term if tag.term else tag }}\n{%- if not loop.last %}, {% endif -%}\n{%- endfor %}<br>\n{%- endif -%}\n{%- if entry.category -%}\n<strong>Category:</strong> {{ entry.category }}<br>\n{%- endif -%}\n{%- if entry.comments -%}\n<strong>Comments:</strong> <a href=\"{{ entry.comments }}\">{{ entry.comments }}</a><br>\n{%- endif -%}\n{%- if entry.slash_comments -%}\n<strong>Comment Count:</strong> {{ entry.slash_comments }}<br>\n{%- endif -%}\n{%- if entry.enclosures -%}\n<strong>Enclosures:</strong><br>\n{%- for enclosure in entry.enclosures %}\n- <a href=\"{{ enclosure.href }}\">{{ enclosure.href }}</a> ({{ enclosure.type if enclosure.type else 'unknown type' }}\n{%- if enclosure.length %}, {{ enclosure.length }} bytes{% endif -%}\n)<br>\n{%- endfor -%}\n{%- endif -%}\n{%- if entry.media_content -%}\n<strong>Media:</strong><br>\n{%- for media in entry.media_content %}\n- <a href=\"{{ media.url }}\">{{ media.url }}</a>\n{%- if media.type %} ({{ media.type }}){% endif -%}\n{%- if media.width and media.height %} {{ media.width }}x{{ media.height }}{% endif -%}\n<br>\n{%- endfor -%}\n{%- endif -%}\n{%- if entry.media_thumbnail -%}\n<strong>Thumbnail:</strong> <a href=\"{{ entry.media_thumbnail[0].url if entry.media_thumbnail[0].url else entry.media_thumbnail[0] }}\">{{ entry.media_thumbnail[0].url if entry.media_thumbnail[0].url else entry.media_thumbnail[0] }}</a><br>\n{%- endif -%}\n{%- if entry.media_description -%}\n<strong>Media Description:</strong> {{ entry.media_description }}<br>\n{%- endif -%}\n{%- if entry.itunes_duration -%}\n<strong>Duration:</strong> {{ entry.itunes_duration }}<br>\n{%- endif -%}\n{%- if entry.itunes_author -%}\n<strong>Podcast Author:</strong> {{ entry.itunes_author }}<br>\n{%- endif -%}\n{%- if entry.dc_identifier -%}\n<strong>Identifier:</strong> {{ entry.dc_identifier }}<br>\n{%- endif -%}\n{%- if entry.dc_source -%}\n<strong>DC Source:</strong> {{ entry.dc_source }}<br>\n{%- endif -%}\n{%- if entry.dc_type -%}\n<strong>Type:</strong> {{ entry.dc_type }}<br>\n{%- endif -%}\n{%- if entry.dc_format -%}\n<strong>Format:</strong> {{ entry.dc_format }}<br>\n{%- endif -%}\n{%- if entry.dc_relation -%}\n<strong>Related:</strong> {{ entry.dc_relation }}<br>\n{%- endif -%}\n{%- if entry.dc_coverage -%}\n<strong>Coverage:</strong> {{ entry.dc_coverage }}<br>\n{%- endif -%}\n{%- if entry.source and entry.source.title -%}\n<strong>Source:</strong> {{ entry.source.title }}\n{%- if entry.source.link %} (<a href=\"{{ entry.source.link }}\">{{ entry.source.link }}</a>){% endif -%}\n<br>\n{%- endif -%}\n{%- if entry.dc_content -%}\n<strong>Content:</strong> {{ entry.dc_content | safe }}\n{%- elif entry.content and entry.content[0].value -%}\n<strong>Content:</strong> {{ entry.content[0].value | safe }}\n{%- elif entry.summary -%}\n<strong>Summary:</strong> {{ entry.summary | safe }}\n{%- endif -%}</article>\n\"\"\"\n\n\ndef format_rss_items(rss_content: str, render_anchor_tag_content=False) -> str:\n    \"\"\"\n    Format RSS/Atom feed items in a readable text format using feedparser and Jinja2.\n\n    Converts RSS <item> or Atom <entry> elements to formatted text with all available fields:\n    - Basic fields: title, link, id/guid, published date, updated date\n    - Author fields: author, author_detail, contributors, publisher\n    - Content fields: content, summary, description\n    - Metadata: tags, category, rights, license\n    - Media: enclosures, media_content, media_thumbnail\n    - Dublin Core elements: dc:creator, dc:date, dc:publisher, etc. (mapped by feedparser)\n\n    Args:\n        rss_content: The RSS/Atom feed content\n        render_anchor_tag_content: Whether to render anchor tag content in descriptions (unused, kept for compatibility)\n\n    Returns:\n        Formatted HTML content ready for html_to_text conversion\n    \"\"\"\n    try:\n        import feedparser\n        from changedetectionio.jinja2_custom import safe_jinja\n\n        # Parse the feed - feedparser handles all RSS/Atom variants, CDATA, entity unescaping, etc.\n        feed = feedparser.parse(rss_content)\n\n        # Determine feed type for appropriate labels\n        is_atom = feed.version and 'atom' in feed.version\n\n        formatted_items = []\n        for entry in feed.entries:\n            # Render the entry using Jinja2 template\n            rendered = safe_jinja.render(RSS_ENTRY_TEMPLATE, entry=entry, is_atom=is_atom)\n            formatted_items.append(rendered.strip())\n\n        # Wrap each item in a div with classes (first, last, item-N)\n        items_html = []\n        total_items = len(formatted_items)\n        for idx, item in enumerate(formatted_items):\n            classes = ['rss-item']\n            if idx == 0:\n                classes.append('first')\n            if idx == total_items - 1:\n                classes.append('last')\n            classes.append(f'item-{idx + 1}')\n\n            class_str = ' '.join(classes)\n            items_html.append(f'<div class=\"{class_str}\">{item}</div>')\n\n        return '<html><body>\\n' + \"\\n<br>\".join(items_html) + '\\n</body></html>'\n\n    except Exception as e:\n        logger.warning(f\"Error formatting RSS items: {str(e)}\")\n        # Fall back to original content\n        return rss_content\n"
  },
  {
    "path": "changedetectionio/run_basic_tests.sh",
    "content": "#!/bin/bash\n\n\n# live_server will throw errors even with live_server_scope=function if I have the live_server setup in different functions\n# and I like to restart the server for each test (and have the test cleanup after each test)\n# merge request welcome :)\n\n\n# exit when any command fails\nset -e\n\nSCRIPT_DIR=$( cd -- \"$( dirname -- \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\nrm tests/logs/* -f\n\n# Since theres no curl installed lets roll with python3\ncheck_sanity() {\n  local port=\"$1\"\n  if [ -z \"$port\" ]; then\n    echo \"Usage: check_sanity <port>\" >&2\n    return 1\n  fi\n\n  python3 - \"$port\" <<'PYCODE'\nimport sys, time, urllib.request, socket\n\nport = sys.argv[1]\nurl = f'http://localhost:{port}'\nok = False\n\nfor _ in range(6):  # --retry 6\n    try:\n        r = urllib.request.urlopen(url, timeout=3).read().decode()\n        if 'est-url-is-sanity' in r:\n            ok = True\n            break\n    except (urllib.error.URLError, ConnectionRefusedError, socket.error):\n        time.sleep(1)\nsys.exit(0 if ok else 1)\nPYCODE\n}\n\ndata_sanity_test () {\n  # Restart data sanity test\n  cd ..\n  TMPDIR=$(mktemp -d)\n  PORT_N=$((5000 + RANDOM % (6501 - 5000)))\n  ALLOW_IANA_RESTRICTED_ADDRESSES=true ./changedetection.py -p $PORT_N -d $TMPDIR -u \"https://localhost?test-url-is-sanity=1\" &\n  PID=$!\n  sleep 5\n  kill $PID\n  sleep 2\n  ALLOW_IANA_RESTRICTED_ADDRESSES=true ./changedetection.py -p $PORT_N -d $TMPDIR &\n  PID=$!\n  sleep 5\n  # On a restart the URL should still be there\n  check_sanity $PORT_N || exit 1\n  kill $PID\n  cd $OLDPWD\n\n  # datastore looks alright, continue\n}\n\ndata_sanity_test\n\necho \"-------------------- Running rest of tests in parallel -------------------------------\"\n\n# REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser\nFETCH_WORKERS=2 REMOVE_REQUESTS_OLD_SCREENSHOTS=false \\\npytest tests/test_*.py \\\n  -n 8 \\\n  --dist=load \\\n  -vvv \\\n  -s \\\n  --capture=no \\\n  -k \"not test_queue_system\" \\\n  --log-cli-level=DEBUG \\\n  --log-cli-format=\"%(asctime)s [%(process)d] [%(levelname)s] %(name)s: %(message)s\"\n\necho \"---------------------------- DONE parallel test ---------------------------------------\"\n\nFETCH_WORKERS=20 pytest -vvv -s tests/test_queue_handler.py\n\necho \"RUNNING WITH BASE_URL SET\"\n\n# Now re-run some tests with BASE_URL enabled\n# Re #65 - Ability to include a link back to the installation, in the notification.\nexport BASE_URL=\"https://really-unique-domain.io\"\n\n# Re-run with HIDE_REFERER set - could affect login\nexport HIDE_REFERER=True\nREMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py tests/test_access_control.py\n\n\n# Re-run a few tests that will trigger brotli based storage\n# And again with brotli+screenshot attachment\nSNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 --dist=load tests/test_backend.py tests/test_rss.py tests/test_unique_lines.py tests/test_notification.py  tests/test_access_control.py\n\n# Try high concurrency with aggressive worker restarts\nFETCH_WORKERS=50 WORKER_MAX_RUNTIME=2 WORKER_MAX_JOBS=1 pytest  tests/test_history_consistency.py -vv -l -s\n\n# Check file:// will pickup a file when enabled\necho \"Hello world\" > /tmp/test-file.txt\nALLOW_FILE_URI=yes pytest -vv -s  tests/test_security.py\n\n\n# Run it again so that brotli kicks in\nTEST_WITH_BROTLI=1 SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=100 FETCH_WORKERS=20 pytest tests/test_history_consistency.py -vv -l -s\n"
  },
  {
    "path": "changedetectionio/run_custom_browser_url_tests.sh",
    "content": "#!/bin/bash\n\n# run some tests and look if the 'custom-browser-search-string=1' connect string appeared in the correct containers\n\n# @todo do it again but with the puppeteer one\n\n# enable debug\nset -x\ndocker network inspect changedet-network >/dev/null 2>&1 || docker network create changedet-network\ndocker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size=\"2g\"  selenium/standalone-chrome:4\n\n# A extra browser is configured, but we never chose to use it, so it should NOT show in the logs\ndocker run --rm -e \"PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000\" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/custom_browser_url/test_custom_browser_url.py::test_request_not_via_custom_browser_url'\ndocker logs sockpuppetbrowser-custom-url &>log-custom.txt\ngrep 'custom-browser-search-string=1' log-custom.txt\nif [ $? -ne 1 ]\nthen\n  echo \"Saw a request in 'sockpuppetbrowser-custom-url' container with 'custom-browser-search-string=1' when I should not - log-custom.txt\"\n  exit 1\nfi\n\ndocker logs sockpuppetbrowser &>log.txt\ngrep 'custom-browser-search-string=1' log.txt\nif [ $? -ne 1 ]\nthen\n  echo \"Saw a request in 'browser' container with 'custom-browser-search-string=1' when I should not\"\n  exit 1\nfi\n\n# Special connect string should appear in the custom-url container, but not in the 'default' one\ndocker run --rm -e \"PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000\" --network changedet-network test-changedetectionio  bash -c 'cd changedetectionio;pytest tests/custom_browser_url/test_custom_browser_url.py::test_request_via_custom_browser_url'\ndocker logs sockpuppetbrowser-custom-url &>log-custom.txt\ngrep 'custom-browser-search-string=1' log-custom.txt\nif [ $? -ne 0 ]\nthen\n  echo \"Did not see request in 'sockpuppetbrowser-custom-url' container with 'custom-browser-search-string=1' when I should - log-custom.txt\"\n  exit 1\nfi\n\ndocker logs sockpuppetbrowser &>log.txt\ngrep 'custom-browser-search-string=1' log.txt\nif [ $? -ne 1 ]\nthen\n  echo \"Saw a request in 'browser' container with 'custom-browser-search-string=1' when I should not\"\n  exit 1\nfi\n\n\n"
  },
  {
    "path": "changedetectionio/run_proxy_tests.sh",
    "content": "#!/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 on different ports\n# Each squid adds a different header to the response, which is the main thing we test for.\ndocker run --network changedet-network -d --name squid-one --hostname squid-one --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf ubuntu/squid:4.13-21.10_edge\ndocker run --network changedet-network -d --name squid-two --hostname squid-two --rm -v `pwd`/tests/proxy_list/squid.conf:/etc/squid/conf.d/debian.conf ubuntu/squid:4.13-21.10_edge\n\n# Used for configuring a custom proxy URL via the UI - with username+password auth\ndocker run --network changedet-network -d \\\n  --name squid-custom \\\n  --hostname squid-custom \\\n  --rm \\\n  -v `pwd`/tests/proxy_list/squid-auth.conf:/etc/squid/conf.d/debian.conf \\\n  -v `pwd`/tests/proxy_list/squid-passwords.txt:/etc/squid3/passwords \\\n  ubuntu/squid:4.13-21.10_edge\n\nsleep 5\n## 2nd test actually choose the preferred proxy from proxies.json\n# This will force a request via \"proxy-two\"\ndocker run --network changedet-network \\\n  -v `pwd`/tests/proxy_list/proxies.json-example:/tmp/proxies.json \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && pytest -s tests/proxy_list/test_multiple_proxy.py --datastore-path /tmp'\n\nset +e\necho \"- Looking for chosen.changedetection.io request in squid-one - it should NOT be here\"\ndocker logs squid-one 2>/dev/null|grep chosen.changedetection.io\nif [ $? -ne 1 ]\nthen\n  echo \"Saw a request to chosen.changedetection.io in the squid logs (while checking preferred proxy - squid one) WHEN I SHOULD NOT\"\n  exit 1\nfi\n\nset -e\necho \"- Looking for chosen.changedetection.io request in squid-two\"\n# And one in the 'second' squid (user selects this as preferred)\ndocker logs squid-two 2>/dev/null|grep chosen.changedetection.io\nif [ $? -ne 0 ]\nthen\n  echo \"Did not see a request to chosen.changedetection.io in the squid logs (while checking preferred proxy - squid two)\"\n  exit 1\nfi\n\n# Test the UI configurable proxies\ndocker run --network changedet-network \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && pytest tests/proxy_list/test_select_custom_proxy.py --datastore-path /tmp'\n\n# Give squid proxies a moment to flush their logs\nsleep 2\n\n# Should see a request for one.changedetection.io in there\necho \"- Looking for .changedetection.io request in squid-custom\"\ndocker logs squid-custom 2>/dev/null|grep \"TCP_TUNNEL.200.*changedetection.io\"\nif [ $? -ne 0 ]\nthen\n  echo \"Did not see a valid request to changedetection.io in the squid logs (while checking preferred proxy - squid two)\"\n  exit 1\nfi\n\n# Test \"no-proxy\" option\ndocker run --network changedet-network \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && pytest tests/proxy_list/test_noproxy.py --datastore-path /tmp'\n\n# Give squid proxies a moment to flush their logs\nsleep 2\n\n# We need to handle grep returning 1\nset +e\n# Check request was never seen in any container\nfor c in $(echo \"squid-one squid-two squid-custom\"); do\n  echo ....Checking $c\n  docker logs $c &> $c.txt\n  grep noproxy $c.txt\n  if [ $? -ne 1 ]\n  then\n    echo \"Saw request for noproxy in $c container\"\n    cat $c.txt\n    exit 1\n  fi\ndone\n\necho \"docker ps output\"\ndocker ps\n\ndocker kill squid-one squid-two squid-custom\n\n# Test that the UI is returning the correct error message when a proxy is not available\n\n# Requests\ndocker run --network changedet-network \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && pytest tests/proxy_list/test_proxy_noconnect.py --datastore-path /tmp'\n\n# Playwright\ndocker run --network changedet-network \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000 pytest tests/proxy_list/test_proxy_noconnect.py --datastore-path /tmp'\n\n# Puppeteer fast\ndocker run --network changedet-network \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && FAST_PUPPETEER_CHROME_FETCHER=1 PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000 pytest tests/proxy_list/test_proxy_noconnect.py --datastore-path /tmp'\n\n# Selenium\ndocker run --network changedet-network \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && WEBDRIVER_URL=http://selenium:4444/wd/hub pytest tests/proxy_list/test_proxy_noconnect.py --datastore-path /tmp'\n"
  },
  {
    "path": "changedetectionio/run_socks_proxy_tests.sh",
    "content": "#!/bin/bash\n\n# exit when any command fails\nset -e\n# enable debug\nset -x\n\ndocker network inspect changedet-network >/dev/null 2>&1 || docker network create changedet-network\n\n# SOCKS5 related - start simple Socks5 proxy server\n# SOCKSTEST=xyz should show in the logs of this service to confirm it fetched\ndocker run --network changedet-network -d --hostname socks5proxy --rm  --name socks5proxy -p 1080:1080 -e PROXY_USER=proxy_user123 -e PROXY_PASSWORD=proxy_pass123 serjs/go-socks5-proxy\ndocker run --network changedet-network -d --hostname socks5proxy-noauth --rm -p 1081:1080 --name socks5proxy-noauth -e REQUIRE_AUTH=false serjs/go-socks5-proxy\n\necho \"---------------------------------- SOCKS5 -------------------\"\n# SOCKS5 related - test from proxies.json\ndocker run --network changedet-network \\\n  -v `pwd`/tests/proxy_socks5/proxies.json-example:/tmp/proxies.json \\\n  --rm \\\n  -e \"FLASK_SERVER_NAME=cdio\" \\\n  --hostname cdio \\\n  -e \"SOCKSTEST=proxiesjson\" \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy_sources.py  --datastore-path /tmp'\n\n# SOCKS5 related - by manually entering in UI\ndocker run --network changedet-network \\\n  --rm \\\n  -e \"FLASK_SERVER_NAME=cdio\" \\\n  --hostname cdio \\\n  -e \"SOCKSTEST=manual\" \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy.py --datastore-path /tmp'\n\n# SOCKS5 related - test from proxies.json via playwright - NOTE- PLAYWRIGHT DOESNT SUPPORT AUTHENTICATING PROXY\ndocker run --network changedet-network \\\n  -e \"SOCKSTEST=manual-playwright\" \\\n  --hostname cdio \\\n  -e \"FLASK_SERVER_NAME=cdio\" \\\n  -v `pwd`/tests/proxy_socks5/proxies.json-example-noauth:/tmp/proxies.json \\\n  -e \"PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000\" \\\n  --rm \\\n  test-changedetectionio \\\n  bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy_sources.py --datastore-path /tmp'\n\necho \"socks5 server logs\"\ndocker logs socks5proxy\necho \"----------------------------------\"\n\ndocker kill socks5proxy socks5proxy-noauth\n"
  },
  {
    "path": "changedetectionio/static/favicons/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"favicons/mstile-150x150.png\"/>\n            <TileColor>#da532c</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>\n"
  },
  {
    "path": "changedetectionio/static/favicons/site.webmanifest",
    "content": "{\n    \"name\": \"ChangeDetection.io\",\n    \"short_name\": \"ChangeDetect\",\n    \"description\": \"Self-hosted website change detection and monitoring\",\n    \"icons\": [\n        {\n            \"src\": \"android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\",\n            \"purpose\": \"any maskable\"\n        },\n        {\n            \"src\": \"android-chrome-256x256.png\",\n            \"sizes\": \"256x256\",\n            \"type\": \"image/png\",\n            \"purpose\": \"any maskable\"\n        }\n    ],\n    \"start_url\": \"/\",\n    \"theme_color\": \"#5bbad5\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"standalone\",\n    \"categories\": [\"utilities\", \"productivity\"],\n    \"orientation\": \"any\"\n}\n"
  },
  {
    "path": "changedetectionio/static/js/browser-steps.js",
    "content": "$(document).ready(function () {\n\n    var browsersteps_session_id;\n    var browser_interface_seconds_remaining = 0;\n    var apply_buttons_disabled = false;\n    var include_text_elements = $(\"#include_text_elements\");\n    var xpath_data = false;\n    var current_selected_i;\n    var state_clicked = false;\n    var c;\n\n    // redline highlight context\n    var ctx;\n    var last_click_xy = {'x': -1, 'y': -1}\n\n    $(window).resize(function () {\n        set_scale();\n    });\n    // Should always be disabled\n\n    $('#browsersteps-click-start').click(function () {\n        $(\"#browsersteps-click-start\").fadeOut();\n        $(\"#browsersteps-selector-wrapper .spinner\").fadeIn();\n        start();\n    });\n\n    $('a#browsersteps-tab').click(function () {\n        reset();\n    });\n\n    window.addEventListener('hashchange', function () {\n        if (window.location.hash == '#browser-steps') {\n            reset();\n        }\n    });\n\n    function reset() {\n        xpath_data = false;\n        $('#browsersteps-img').removeAttr('src');\n        $(\"#browsersteps-click-start\").show();\n        $(\"#browsersteps-selector-wrapper .spinner\").hide();\n        browser_interface_seconds_remaining = 0;\n        browsersteps_session_id = false;\n        apply_buttons_disabled = false;\n        ctx.clearRect(0, 0, c.width, c.height);\n    }\n\n    // Show seconds remaining until the browser interface needs to restart the session\n    // (See comment at the top of changedetectionio/blueprint/browser_steps/__init__.py )\n    setInterval(() => {\n        if (browser_interface_seconds_remaining >= 1) {\n            document.getElementById('browser-seconds-remaining').innerText = browser_interface_seconds_remaining + \" seconds remaining in session\";\n            browser_interface_seconds_remaining -= 1;\n        }\n    }, \"1000\")\n\n\n    function set_scale() {\n\n        // some things to check if the scaling doesnt work\n        // - that the widths/sizes really are about the actual screen size cat elements.json |grep -o width......|sort|uniq\n        selector_image = $(\"img#browsersteps-img\")[0];\n        selector_image_rect = selector_image.getBoundingClientRect();\n\n        // make the canvas and input steps the same size as the image\n        $('#browsersteps-selector-canvas').attr('height', selector_image_rect.height).attr('width', selector_image_rect.width);\n        //$('#browsersteps-selector-wrapper').attr('width', selector_image_rect.width);\n        $('#browser-steps-ui').attr('width', selector_image_rect.width);\n\n        x_scale = selector_image_rect.width / xpath_data['browser_width'];\n        y_scale = selector_image_rect.height / selector_image.naturalHeight;\n        ctx.strokeStyle = 'rgba(255,0,0, 0.9)';\n        ctx.fillStyle = 'rgba(255,0,0, 0.1)';\n        ctx.lineWidth = 3;\n        console.log(\"scaling set  x: \" + x_scale + \" by y:\" + y_scale);\n    }\n\n    // bootstrap it, this will trigger everything else\n    $('#browsersteps-img').bind('load', function () {\n        $('body').addClass('full-width');\n        console.log(\"Loaded background...\");\n\n        document.getElementById(\"browsersteps-selector-canvas\");\n        c = document.getElementById(\"browsersteps-selector-canvas\");\n        // redline highlight context\n        ctx = c.getContext(\"2d\");\n        // @todo is click better?\n        $('#browsersteps-selector-canvas').off(\"mousemove mousedown click\");\n        // Undo disable_browsersteps_ui\n        $(\"#browser-steps-ui\").css('opacity', '1.0');\n\n        // init\n        set_scale();\n\n        // @todo click ? some better library?\n        $('#browsersteps-selector-canvas').bind('click', function (e) {\n            // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent\n            e.preventDefault()\n        });\n\n        // When the mouse moves we know which element it should be above\n        // mousedown will link that to the UI (select the right action, highlight etc)\n        $('#browsersteps-selector-canvas').bind('mousedown', function (e) {\n            // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent\n            e.preventDefault()\n            last_click_xy = {'x': parseInt((1 / x_scale) * e.offsetX), 'y': parseInt((1 / y_scale) * e.offsetY)}\n            process_selected(current_selected_i);\n            current_selected_i = false;\n\n            // if process selected returned false, then best we can do is offer a x,y click :(\n            if (!found_something) {\n                var first_available = $(\"ul#browser_steps li.empty\").first();\n                $('select', first_available).val('Click X,Y').change();\n                $('input[type=text]', first_available).first().val(last_click_xy['x'] + ',' + last_click_xy['y']);\n                draw_circle_on_canvas(e.offsetX, e.offsetY);\n            }\n        });\n\n        // Debounce and find the current most 'interesting' element we are hovering above\n        $('#browsersteps-selector-canvas').bind('mousemove', function (e) {\n            if (!xpath_data) {\n                return;\n            }\n\n            // checkbox if find elements is enabled\n            ctx.clearRect(0, 0, c.width, c.height);\n            ctx.fillStyle = 'rgba(255,0,0, 0.1)';\n            ctx.strokeStyle = 'rgba(255,0,0, 0.9)';\n\n            // Add in offset\n            if ((typeof e.offsetX === \"undefined\" || typeof e.offsetY === \"undefined\") || (e.offsetX === 0 && e.offsetY === 0)) {\n                var targetOffset = $(e.target).offset();\n                e.offsetX = e.pageX - targetOffset.left;\n                e.offsetY = e.pageY - targetOffset.top;\n            }\n            current_selected_i = false;\n            // Reverse order - the most specific one should be deeper/\"laster\"\n            // Basically, find the most 'deepest'\n            var possible_elements = [];\n            xpath_data['size_pos'].forEach(function (item, index) {\n                // If we are in a bounding-box\n                if (e.offsetY > item.top * y_scale && e.offsetY < item.top * y_scale + item.height * y_scale\n                    &&\n                    e.offsetX > item.left * y_scale && e.offsetX < item.left * y_scale + item.width * y_scale\n\n                ) {\n                    // Ignore really large ones, because we are scraping 'div' also from xpath_element_scraper but\n                    // that div or whatever could be some wrapper and would generally make you select the whole page\n                    if (item.width > 800 && item.height > 400) {\n                        return\n                    }\n\n                    // There could be many elements here, record them all and then we'll find out which is the most 'useful'\n                    // (input, textarea, button, A etc)\n                    if (item.width < xpath_data['browser_width']) {\n                        possible_elements.push(item);\n                    }\n                }\n            });\n\n            // Find the best one\n            if (possible_elements.length) {\n                possible_elements.forEach(function (item, index) {\n                  if ([\"a\", \"input\", \"textarea\", \"button\"].includes(item['tagName'])) {\n                      current_selected_i = item;\n                  }\n                });\n\n                if (!current_selected_i) {\n                    current_selected_i = possible_elements[0];\n                }\n\n                sel = xpath_data['size_pos'][current_selected_i];\n                ctx.strokeRect(current_selected_i.left * x_scale, current_selected_i.top * y_scale, current_selected_i.width * x_scale, current_selected_i.height * y_scale);\n                ctx.fillRect(current_selected_i.left * x_scale, current_selected_i.top * y_scale, current_selected_i.width * x_scale, current_selected_i.height * y_scale);\n            }\n\n\n        }.debounce(10));\n    });\n\n//    $(\"#browser-steps-fieldlist\").bind('mouseover', function(e) {\n//        console.log(e.xpath_data_index);\n    // });\n\n\n    // callback for clicking on an xpath on the canvas\n    function process_selected(selected_in_xpath_list) {\n        found_something = false;\n        var first_available = $(\"ul#browser_steps li.empty\").first();\n\n\n        if (selected_in_xpath_list !== false) {\n            // Nothing focused, so fill in a new one\n            // if inpt type button or <button>\n            // from the top, find the next not used one and use it\n            var x = selected_in_xpath_list;\n            console.log(x);\n            if (x && first_available.length) {\n                // @todo will it let you click shit that has a layer ontop? probably not.\n                if (x['tagtype'] === 'text' || x['tagtype'] === 'number' || x['tagtype'] === 'email' || x['tagName'] === 'textarea' || x['tagtype'] === 'password' || x['tagtype'] === 'search') {\n                    $('select', first_available).val('Enter text in field').change();\n                    $('input[type=text]', first_available).first().val(x['xpath']);\n                    $('input[placeholder=\"Value\"]', first_available).addClass('ok').click().focus();\n                    found_something = true;\n                }\n                else if (x['tagName'] === 'select') {\n                    $('select', first_available).val('<select> by option text').change();\n                    $('input[type=text]', first_available).first().val(x['xpath']);\n                    $('input[placeholder=\"Value\"]', first_available).addClass('ok').click().focus();\n                    found_something = true;\n                }\n                else {\n                    // There's no good way (that I know) to find if this\n                    // see https://stackoverflow.com/questions/446892/how-to-find-event-listeners-on-a-dom-node-in-javascript-or-in-debugging\n                    // https://codepen.io/azaslavsky/pen/DEJVWv\n\n                    // So we dont know if its really a clickable element or not :-(\n                    // Assume it is - then we dont fill the pages with unreliable \"Click X,Y\" selections\n                    // If you switch to \"Click X,y\" after an element here is setup, it will give the last co-ords anyway\n                    //if (x['isClickable'] || x['tagName'].startsWith('h') || x['tagName'] === 'a' || x['tagName'] === 'button' || x['tagtype'] === 'submit' || x['tagtype'] === 'checkbox' || x['tagtype'] === 'radio' || x['tagtype'] === 'li') {\n                        $('select', first_available).val('Click element').change();\n                        $('input[type=text]', first_available).first().val(x['xpath']).focus();\n                        found_something = true;\n                    //}\n                }\n            }\n        }\n    }\n\n    function draw_circle_on_canvas(x, y) {\n        ctx.beginPath();\n        ctx.arc(x, y, 8, 0, 2 * Math.PI, false);\n        ctx.fillStyle = 'rgba(255,0,0, 0.6)';\n        ctx.fill();\n    }\n\n    // Reusable AJAX function for browser step operations\n    function executeBrowserStep(url, data = {}) {\n        $('#browser-steps-ui .loader .spinner').fadeIn();\n        apply_buttons_disabled = true;\n        $('ul#browser_steps li .control .apply').css('opacity', 0.5);\n        $(\"#browsersteps-img\").css('opacity', 0.65);\n\n        return $.ajax({\n            method: \"POST\",\n            url: url,\n            data: data,\n            statusCode: {\n                400: function () {\n                    alert(\"There was a problem processing the request, please reload the page.\");\n                    $(\"#loading-status-text\").hide();\n                    $('#browser-steps-ui .loader .spinner').fadeOut();\n                },\n                401: function (data) {\n                    alert(data.responseText);\n                    $(\"#loading-status-text\").hide();\n                    $('#browser-steps-ui .loader .spinner').fadeOut();\n                }\n            }\n        }).done(function (data) {\n            xpath_data = data.xpath_data;\n            $('#browsersteps-img').attr('src', data.screenshot);\n            $('#browser-steps-ui .loader .spinner').fadeOut();\n            apply_buttons_disabled = false;\n            $(\"#browsersteps-img\").css('opacity', 1);\n            $('ul#browser_steps li .control .apply').css('opacity', 1);\n            $(\"#loading-status-text\").hide();\n        }).fail(function (data) {\n            console.log(data);\n            if (data.responseText && data.responseText.includes(\"Browser session expired\")) {\n                disable_browsersteps_ui();\n            }\n            apply_buttons_disabled = false;\n            $(\"#loading-status-text\").hide();\n            $('ul#browser_steps li .control .apply').css('opacity', 1);\n            $(\"#browsersteps-img\").css('opacity', 1);\n        });\n    }\n\n    function start() {\n        console.log(\"Starting browser-steps UI\");\n        browsersteps_session_id = false;\n        $('#browser-steps-ui .loader .spinner').show();\n        // Request a new session\n        $.ajax({\n            type: \"GET\",\n            url: browser_steps_start_url,\n            statusCode: {\n                400: function () {\n                    // More than likely the CSRF token was lost when the server restarted\n                    alert(\"There was a problem processing the request, please reload the page.\");\n                },\n                401: function (err) {\n                    // This will be a custom error\n                    alert(err.responseText);\n                }\n            }\n        }).done(function (data) {\n            $(\"#loading-status-text\").fadeIn();\n            browsersteps_session_id = data.browsersteps_session_id;\n            browser_interface_seconds_remaining = 500;\n            // Request goto_site operation\n            executeBrowserStep(\n                browser_steps_sync_url + \"&browsersteps_session_id=\" + browsersteps_session_id + \"&goto_website_url_first_step=true\"\n            );\n\n        }).fail(function (data) {\n            console.log(data);\n            alert('There was an error communicating with the server.');\n        });\n\n    }\n\n    function disable_browsersteps_ui() {\n        $(\"#browser-steps-ui\").css('opacity', '0.3');\n        $('#browsersteps-selector-canvas').off(\"mousemove mousedown click\");\n    }\n\n\n    ////////////////////////// STEPS UI ////////////////////\n    $('ul#browser_steps [type=\"text\"]').keydown(function (e) {\n        if (e.keyCode === 13) {\n            // hitting [enter] in a browser-step input should trigger the 'Apply'\n            e.preventDefault();\n            $(\".apply\", $(this).closest('li')).click();\n            return false;\n        }\n    });\n\n    // Look up which step was selected, and enable or disable the related extra fields\n    // So that people using it dont' get confused\n    $('ul#browser_steps select').on(\"change\", function () {\n        var config = browser_steps_config[$(this).val()].split(' ');\n        var elem_selector = $('tr:nth-child(2) input', $(this).closest('tbody'));\n        var elem_value = $('tr:nth-child(3) input', $(this).closest('tbody'));\n\n        if (config[0] == 0) {\n            $(elem_selector).fadeOut();\n        } else {\n            $(elem_selector).fadeIn();\n        }\n        if (config[1] == 0) {\n            $(elem_value).fadeOut();\n        } else {\n            $(elem_value).fadeIn();\n        }\n\n        if ($(this).val() === 'Click X,Y' && last_click_xy['x'] > 0 && $(elem_value).val().length === 0) {\n            // @todo handle scale\n            $(elem_value).val(last_click_xy['x'] + ',' + last_click_xy['y']).focus();\n        }\n    }).change();\n\n    function set_greyed_state() {\n        $('ul#browser_steps select').not('option:selected[value=\"Choose one\"]').closest('li').removeClass('empty');\n        $('ul#browser_steps select option:selected[value=\"Choose one\"]').closest('li').addClass('empty');\n    }\n\n    // Add the extra buttons to the steps\n    $('ul#browser_steps li').each(function (i) {\n            var s = '<div class=\"control\">' + '<a data-step-index=' + i + ' class=\"pure-button button-secondary button-green button-xsmall apply\" >Apply</a>&nbsp;';\n            s += `<a data-step-index=\"${i}\" class=\"pure-button button-secondary button-xsmall clear\" >Clear</a>&nbsp;` +\n                `<a data-step-index=\"${i}\" class=\"pure-button button-secondary button-red button-xsmall remove\" >Remove</a>`;\n\n            // if a screenshot is available\n            if (browser_steps_available_screenshots.includes(i.toString())) {\n                var d = (browser_steps_last_error_step === i+1) ? 'before' : 'after';\n                s += `&nbsp;<a data-step-index=\"${i}\" class=\"pure-button button-secondary button-xsmall show-screenshot\" title=\"Show screenshot from last run\" data-type=\"${d}\">Pic</a>&nbsp;`;\n            }\n            s += '</div>';\n            $(this).append(s)\n        }\n    );\n\n    $('ul#browser_steps li .control .clear').click(function (element) {\n        $(\"select\", $(this).closest('li')).val(\"Choose one\").change();\n        $(\":text\", $(this).closest('li')).val('');\n    });\n\n\n    $('ul#browser_steps li .control .remove').click(function (element) {\n        // so you wanna remove the 2nd (3rd spot 0,1,2,...)\n        var p = $(\"#browser_steps li\").index($(this).closest('li'));\n\n        var elem_to_remove = $(\"#browser_steps li\")[p];\n        $('.clear', elem_to_remove).click();\n        $(\"#browser_steps li\").slice(p, 10).each(function (index) {\n            // get the next one's value from where we clicked\n            var next = $(\"#browser_steps li\")[p + index + 1];\n            if (next) {\n                // and set THIS ones value from the next one\n                var n = $('input', next);\n                $(\"select\", $(this)).val($('select', next).val());\n                $('input', this)[0].value = $(n)[0].value;\n                $('input', this)[1].value = $(n)[1].value;\n                // Triggers reconfiguring the field based on the system config\n                $(\"select\", $(this)).change();\n            }\n\n        });\n\n        // Reset their hidden/empty states\n        set_greyed_state();\n    });\n\n    $('ul#browser_steps li .control .apply').click(function (event) {\n        if (apply_buttons_disabled) {\n            return;\n        }\n\n        var current_data = $(event.currentTarget).closest('li');\n        var step_n = $(event.currentTarget).data('step-index');\n\n        // Determine if this is the last configured step\n        var is_last_step = 0;\n        $('ul#browser_steps li select').each(function (i) {\n            if ($(this).val() !== 'Choose one') {\n                is_last_step += 1;\n            }\n        });\n        is_last_step = (is_last_step == (step_n + 1));\n\n        console.log(\"Requesting step via POST \" + $(\"select[id$='operation']\", current_data).first().val());\n\n        // Execute the browser step\n        executeBrowserStep(\n            browser_steps_sync_url + \"&browsersteps_session_id=\" + browsersteps_session_id,\n            {\n                'operation': $(\"select[id$='operation']\", current_data).first().val(),\n                'selector': $(\"input[id$='selector']\", current_data).first().val(),\n                'optional_value': $(\"input[id$='optional_value']\", current_data).first().val(),\n                'step_n': step_n,\n                'is_last_step': is_last_step\n            }\n        );\n    });\n\n    $('ul#browser_steps li .control .show-screenshot').click(function (element) {\n        var step_n = $(event.currentTarget).data('step-index');\n        w = window.open(this.href, \"_blank\", \"width=640,height=480\");\n        const t = $(event.currentTarget).data('type');\n\n        const url = browser_steps_fetch_screenshot_image_url + `&step_n=${step_n}&type=${t}`;\n        w.document.body.innerHTML = `<!DOCTYPE html>\n            <html lang=\"en\">\n                <body>\n                    <img src=\"${url}\" style=\"width: 100%\" alt=\"Browser Step at step ${step_n} from last run.\" title=\"Browser Step at step ${step_n} from last run.\"/>\n                </body>\n        </html>`;\n        w.document.title = `Browser Step at step ${step_n} from last run.`;\n    });\n\n    if (browser_steps_last_error_step) {\n        $(\"ul#browser_steps>li:nth-child(\"+browser_steps_last_error_step+\")\").addClass(\"browser-step-with-error\");\n    }\n\n    $(\"ul#browser_steps select\").change(function () {\n        set_greyed_state();\n    }).change();\n\n});"
  },
  {
    "path": "changedetectionio/static/js/comparison-slider.js",
    "content": "/**\n * Interactive Image Comparison Slider\n *\n * Allows users to drag a vertical slider to reveal differences between\n * two images (before/after comparison).\n */\n(function() {\n    'use strict';\n\n    function initComparisonSlider() {\n        const container = document.getElementById('comparison-container');\n        const slider = document.getElementById('comparison-slider');\n        const afterWrapper = container ? container.querySelector('.comparison-after') : null;\n        const handle = slider ? slider.querySelector('.comparison-handle') : null;\n\n        if (!container || !slider || !afterWrapper || !handle) {\n            return;\n        }\n\n        let isDragging = false;\n        let isMouseOverContainer = false;\n        let mouseY = 0;\n\n        /**\n         * Update handle position - follows mouse if hovering, or viewport center\n         */\n        function updateHandlePosition() {\n            const containerRect = container.getBoundingClientRect();\n            let handleTop;\n\n            if (isMouseOverContainer) {\n                // Mouse is over container - follow the mouse Y position\n                handleTop = mouseY - containerRect.top;\n            } else {\n                // Mouse not over container - use viewport center\n                const viewportCenter = window.innerHeight / 2;\n\n                if (containerRect.top > viewportCenter) {\n                    // Container is below viewport center - position handle at container top\n                    handleTop = 0;\n                } else if (containerRect.bottom < viewportCenter) {\n                    // Container is above viewport center - position handle at container bottom\n                    handleTop = containerRect.height;\n                } else {\n                    // Container spans viewport center - position handle at viewport center\n                    handleTop = viewportCenter - containerRect.top;\n                }\n            }\n\n            // Ensure handle stays within container bounds\n            handleTop = Math.max(24, Math.min(handleTop, containerRect.height - 24));\n\n            handle.style.top = handleTop + 'px';\n        }\n\n        /**\n         * Track mouse position over container\n         */\n        function onMouseMoveContainer(e) {\n            mouseY = e.clientY;\n            updateHandlePosition();\n        }\n\n        /**\n         * Mouse enters container\n         */\n        function onMouseEnter() {\n            isMouseOverContainer = true;\n        }\n\n        /**\n         * Mouse leaves container\n         */\n        function onMouseLeave() {\n            isMouseOverContainer = false;\n            updateHandlePosition();\n        }\n\n        /**\n         * Update slider position and clip-path\n         */\n        function updateSlider(clientX) {\n            const rect = container.getBoundingClientRect();\n            const pos = Math.max(0, Math.min(clientX - rect.left, rect.width));\n            const percentage = (pos / rect.width) * 100;\n\n            slider.style.left = percentage + '%';\n            afterWrapper.style.clipPath = `inset(0 0 0 ${percentage}%)`;\n        }\n\n        /**\n         * Handle move events (mouse or touch)\n         */\n        function onMove(e) {\n            if (!isDragging) return;\n            e.preventDefault();\n\n            const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;\n            updateSlider(clientX);\n        }\n\n        /**\n         * Start dragging\n         */\n        function onStart(e) {\n            isDragging = true;\n            const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;\n            updateSlider(clientX);\n        }\n\n        /**\n         * Stop dragging\n         */\n        function onEnd() {\n            isDragging = false;\n        }\n\n        /**\n         * Click anywhere on container to jump slider to that position\n         */\n        function onClick(e) {\n            if (e.target !== slider && !slider.contains(e.target)) {\n                const clientX = e.clientX;\n                updateSlider(clientX);\n            }\n        }\n\n        // Mouse events\n        slider.addEventListener('mousedown', onStart);\n        document.addEventListener('mousemove', onMove);\n        document.addEventListener('mouseup', onEnd);\n\n        // Touch events\n        slider.addEventListener('touchstart', onStart, { passive: false });\n        document.addEventListener('touchmove', onMove, { passive: false });\n        document.addEventListener('touchend', onEnd);\n\n        // Click anywhere on container to move slider\n        container.addEventListener('click', onClick);\n\n        // Track mouse position over container for handle following\n        container.addEventListener('mouseenter', onMouseEnter);\n        container.addEventListener('mouseleave', onMouseLeave);\n        container.addEventListener('mousemove', onMouseMoveContainer, { passive: true });\n\n        // Update handle position on scroll and resize\n        window.addEventListener('scroll', updateHandlePosition, { passive: true });\n        window.addEventListener('resize', updateHandlePosition, { passive: true });\n\n        // Initial position\n        updateHandlePosition();\n\n        // Cleanup on page unload\n        window.addEventListener('beforeunload', function() {\n            document.removeEventListener('mousemove', onMove);\n            document.removeEventListener('mouseup', onEnd);\n            document.removeEventListener('touchmove', onMove);\n            document.removeEventListener('touchend', onEnd);\n            container.removeEventListener('mouseenter', onMouseEnter);\n            container.removeEventListener('mouseleave', onMouseLeave);\n            container.removeEventListener('mousemove', onMouseMoveContainer);\n            window.removeEventListener('scroll', updateHandlePosition);\n            window.removeEventListener('resize', updateHandlePosition);\n        });\n    }\n\n    // Initialize when DOM is ready\n    if (document.readyState === 'loading') {\n        document.addEventListener('DOMContentLoaded', initComparisonSlider);\n    } else {\n        initComparisonSlider();\n    }\n})();\n"
  },
  {
    "path": "changedetectionio/static/js/conditions.js",
    "content": "$(document).ready(function () {\n    // Function to set up button event handlers\n    function setupButtonHandlers() {\n        // Unbind existing handlers first to prevent duplicates\n        $(\".addRuleRow, .removeRuleRow, .verifyRuleRow\").off(\"click\");\n        \n        // Add row button handler\n        $(\".addRuleRow\").on(\"click\", function(e) {\n            e.preventDefault();\n            \n            let currentRow = $(this).closest(\".fieldlist-row\");\n            \n            // Clone without events\n            let newRow = currentRow.clone(false);\n            \n            // Reset input values in the cloned row\n            newRow.find(\"input\").val(\"\");\n            newRow.find(\"select\").prop(\"selectedIndex\", 0);\n            \n            // Insert the new row after the current one\n            currentRow.after(newRow);\n            \n            // Reindex all rows\n            reindexRules();\n        });\n        \n        // Remove row button handler\n        $(\".removeRuleRow\").on(\"click\", function(e) {\n            e.preventDefault();\n            \n            // Only remove if there's more than one row\n            if ($(\"#rulesTable .fieldlist-row\").length > 1) {\n                $(this).closest(\".fieldlist-row\").remove();\n                reindexRules();\n            }\n        });\n        \n        // Verify rule button handler\n        $(\".verifyRuleRow\").on(\"click\", function(e) {\n            e.preventDefault();\n            \n            let row = $(this).closest(\".fieldlist-row\");\n            let field = row.find(\"select[name$='field']\").val();\n            let operator = row.find(\"select[name$='operator']\").val();\n            let value = row.find(\"input[name$='value']\").val();\n            \n            // Validate that all fields are filled\n            if (!field || field === \"None\" || !operator || operator === \"None\" || !value) {\n                alert(\"Please fill in all fields (Field, Operator, and Value) before verifying.\");\n                return;\n            }\n\n            \n            // Create a rule object\n            let rule = {\n                field: field,\n                operator: operator,\n                value: value\n            };\n            \n            // Show a spinner or some indication that verification is in progress\n            const $button = $(this);\n            const originalHTML = $button.html();\n            $button.html(\"⌛\").prop(\"disabled\", true);\n            \n            // Collect form data - similar to request_textpreview_update() in watch-settings.js\n            let formData = new FormData();\n            $('#edit-text-filter textarea, #edit-text-filter input').each(function() {\n                const $element = $(this);\n                const name = $element.attr('name');\n                if (name) {\n                    if ($element.is(':checkbox')) {\n                        formData.append(name, $element.is(':checked') ? $element.val() : false);\n                    } else {\n                        formData.append(name, $element.val());\n                    }\n                }\n            });\n            \n            // Also collect select values\n            $('#edit-text-filter select').each(function() {\n                const $element = $(this);\n                const name = $element.attr('name');\n                if (name) {\n                    formData.append(name, $element.val());\n                }\n            });\n\n\n            // Send the request to verify the rule\n            $.ajax({\n                url: verify_condition_rule_url+\"?\"+ new URLSearchParams({ rule: JSON.stringify(rule) }).toString(),\n                type: \"POST\",\n                data: formData,\n                processData: false, // Prevent jQuery from converting FormData to a string\n                contentType: false, // Let the browser set the correct content type\n                success: function (response) {\n                    if (response.status === \"success\") {\n                        if(rule['field'] !== \"page_filtered_text\") {\n                            // A little debug helper for the user\n                            $('#verify-state-text').text(`${rule['field']} was value \"${response.data[rule['field']]}\"`)\n                        }\n                        if (response.result) {\n                            alert(\"✅ Condition PASSES verification against current snapshot!\");\n                        } else {\n                            alert(\"❌ Condition FAILS verification against current snapshot.\");\n                        }\n                    } else {\n                        alert(\"Error: \" + response.message);\n                    }\n                    $button.html(originalHTML).prop(\"disabled\", false);\n                },\n                error: function (xhr) {\n                    let errorMsg = \"Error verifying condition.\";\n                    if (xhr.responseJSON && xhr.responseJSON.message) {\n                        errorMsg = xhr.responseJSON.message;\n                    }\n                    alert(errorMsg);\n                    $button.html(originalHTML).prop(\"disabled\", false);\n                }\n            });\n        });\n    }\n\n    // Function to reindex form elements and re-setup event handlers\n    function reindexRules() {\n        // Unbind all button handlers first\n        $(\".addRuleRow, .removeRuleRow, .verifyRuleRow\").off(\"click\");\n        \n        // Reindex all form elements\n        $(\"#rulesTable .fieldlist-row\").each(function(index) {\n            $(this).find(\"select, input\").each(function() {\n                let oldName = $(this).attr(\"name\");\n                let oldId = $(this).attr(\"id\");\n\n                if (oldName) {\n                    let newName = oldName.replace(/\\d+/, index);\n                    $(this).attr(\"name\", newName);\n                }\n\n                if (oldId) {\n                    let newId = oldId.replace(/\\d+/, index);\n                    $(this).attr(\"id\", newId);\n                }\n            });\n        });\n        \n        // Reattach event handlers after reindexing\n        setupButtonHandlers();\n    }\n\n    // Initial setup of button handlers\n    setupButtonHandlers();\n});\n"
  },
  {
    "path": "changedetectionio/static/js/csrf.js",
    "content": "$(document).ready(function () {\n    $.ajaxSetup({\n        beforeSend: function (xhr, settings) {\n            if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {\n                xhr.setRequestHeader(\"X-CSRFToken\", csrftoken)\n            }\n        }\n    })\n});\n\n"
  },
  {
    "path": "changedetectionio/static/js/diff-overview.js",
    "content": "function setupDiffNavigation() {\n    var $fromSelect = $('#diff-from-version');\n    var $toSelect = $('#diff-to-version');\n    var $fromSelected = $fromSelect.find('option:selected');\n    var $toSelected = $toSelect.find('option:selected');\n\n    if ($fromSelected.length && $toSelected.length) {\n        // Find the previous pair (move both back one position)\n        var $prevFrom = $fromSelected.prev();\n        var $prevTo = $toSelected.prev();\n\n        // Find the next pair (move both forward one position)\n        var $nextFrom = $fromSelected.next();\n        var $nextTo = $toSelected.next();\n\n        // Build URL with current diff preferences\n        var currentParams = new URLSearchParams(window.location.search);\n\n        // Previous button: only show if both can move back\n        if ($prevFrom.length && $prevTo.length) {\n            currentParams.set('from_version', $prevFrom.val());\n            currentParams.set('to_version', $prevTo.val());\n            $('#btn-previous').attr('href', '?' + currentParams.toString());\n        } else {\n            $('#btn-previous').remove();\n        }\n\n        // Next button: only show if both can move forward\n        if ($nextFrom.length && $nextTo.length) {\n            currentParams.set('from_version', $nextFrom.val());\n            currentParams.set('to_version', $nextTo.val());\n            $('#btn-next').attr('href', '?' + currentParams.toString());\n        } else {\n            $('#btn-next').remove();\n        }\n    }\n\n    // Keyboard navigation\n    window.addEventListener('keydown', function (event) {\n        // Don't trigger if user is typing in an input field\n        if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'SELECT') {\n            return;\n        }\n\n        var $fromSelected = $fromSelect.find('option:selected');\n        var $toSelected = $toSelect.find('option:selected');\n\n        if ($fromSelected.length && $toSelected.length) {\n            if (event.key === 'ArrowLeft') {\n                var $prevFrom = $fromSelected.prev();\n                var $prevTo = $toSelected.prev();\n                if ($prevFrom.length && $prevTo.length) {\n                    var prevHref = $('#btn-previous').attr('href');\n                    if (prevHref) {\n                        event.preventDefault();\n                        window.location.href = prevHref;\n                    }\n                }\n            } else if (event.key === 'ArrowRight') {\n                var $nextFrom = $fromSelected.next();\n                var $nextTo = $toSelected.next();\n                if ($nextFrom.length && $nextTo.length) {\n                    var nextHref = $('#btn-next').attr('href');\n                    if (nextHref) {\n                        event.preventDefault();\n                        window.location.href = nextHref;\n                    }\n                }\n            }\n        }\n    }, false);\n}\n\n$(document).ready(function () {\n    $('.needs-localtime').each(function () {\n        for (var option of this.options) {\n            var dateObject = new Date(option.value * 1000);\n            var formattedDate = dateObject.toLocaleString(undefined, {dateStyle: \"full\", timeStyle: \"medium\"});\n            // Preserve any existing text in the label (like \"(Previous)\" or \"(Current)\")\n            var existingText = option.text.replace(option.value, '').trim();\n            option.label = existingText ? formattedDate + ' ' + existingText : formattedDate;\n        }\n    });\n\n    // Setup keyboard navigation for diff versions\n    if ($('#diff-from-version').length && $('#diff-to-version').length) {\n        setupDiffNavigation();\n    }\n\n    // Load it when the #screenshot tab is in use, so we dont give a slow experience when waiting for the text diff to load\n    window.addEventListener('hashchange', function (e) {\n        toggle(location.hash);\n    }, false);\n\n    toggle(location.hash);\n\n    function toggle(hash_name) {\n        if (hash_name === '#screenshot') {\n            $(\"img#screenshot-img\").attr('src', screenshot_url);\n            $(\"#settings\").hide();\n        } else if (hash_name === '#error-screenshot') {\n            $(\"img#error-screenshot-img\").attr('src', error_screenshot_url);\n            $(\"#settings\").hide();\n        } else if (hash_name === '#extract') {\n            $(\"#settings\").hide();\n        } else {\n            $(\"#settings\").show();\n        }\n    }\n\n    const article = $('#difference')[0];\n\n    // We could also add the  'touchend' event for touch devices, but since\n    // most iOS/Android browsers already show a dialog when you select\n    // text (often with a Share option) we'll skip that\n    if (article) {\n        article.addEventListener('mousedown', clean, false);\n    }\n\n    // Because they might 'mouse up' outside the article but on the page\n    const d_page = $(\".difference-page\")[0]\n    if (d_page ) {\n        d_page.addEventListener('mouseup', dragTextHandler, false);\n    }\n\n\n    $('#highlightSnippetActions a').bind('click', function (e) {\n        if (!window.getSelection().toString().trim().length) {\n            alert('Oops no text selected!');\n            return;\n        }\n\n        $.ajax({\n            type: \"POST\",\n            url: highlight_submit_ignore_url,\n            data: {'mode': $(this).data('mode'), 'selection': window.getSelection().toString()},\n            statusCode: {\n                400: function () {\n                    // More than likely the CSRF token was lost when the server restarted\n                    alert(\"There was a problem processing the request, please reload the page.\");\n                }\n            }\n        }).done(function (data) {\n            // @todo some feedback\n            alert(\"'Ignore' Filters for this watch were updated.\")\n            clean();\n\n        }).fail(function (data) {\n            console.log(data);\n            alert('There was an error communicating with the server.');\n        })\n    });\n\n    function clean(event) {\n        $('#bottom-horizontal-offscreen').hide();\n    }\n\n    // Listen for Escape key press\n    window.addEventListener('keydown', function (e) {\n        if (e.key === 'Escape') {\n            clean();\n        }\n    }, false);\n\n    function dragTextHandler(event) {\n        console.log('mouseupped');\n\n        // Check if any text was selected\n        if (window.getSelection().toString().length > 0) {\n            $('#bottom-horizontal-offscreen').show();\n        } else {\n            clean();\n        }\n    }\n\n    $('#diff-form').on('submit', function (e) {\n        if ($('select[name=from_version]').val() === $('select[name=to_version]').val()) {\n            e.preventDefault();\n            alert('Error - You are trying to compare the same version.');\n        }\n    });\n\n    // Auto-submit form on change of any input elements (checkboxes, radio buttons, dropdowns)\n    $('#diff-form').on('change', 'input[type=\"checkbox\"], input[type=\"radio\"], select', function (e) {\n        // Check if we're trying to compare the same version before submitting\n        if ($('select[name=from_version]').val() !== $('select[name=to_version]').val()) {\n            $('#diff-form').submit();\n        }\n    });\n});\n"
  },
  {
    "path": "changedetectionio/static/js/diff-render.js",
    "content": "$(document).ready(function () {\n\n    // Find all <span> elements inside pre#difference\n    var inputs = $('#difference span').toArray();\n    inputs.current = 0;\n\n    // Setup visual minimap of difference locations (cells are pre-built in Python)\n    var $visualizer = $('#cell-diff-jump-visualiser');\n    var $difference = $('#difference');\n    var $cells = $visualizer.find('> div');\n    var visualizerResolutionCells = $cells.length;\n    var cellHeight;\n\n    if ($difference.length && visualizerResolutionCells > 0) {\n        var docHeight = $difference[0].scrollHeight;\n        cellHeight = docHeight / visualizerResolutionCells;\n\n        // Add click handlers to pre-built cells\n        $cells.each(function(i) {\n            $(this).data('cellIndex', i);\n            $(this).on('click', function() {\n                var cellIndex = $(this).data('cellIndex');\n                var targetPositionInDifference = cellIndex * cellHeight;\n                var viewportHeight = $(window).height();\n\n                // Scroll so target is at viewport center (where eyes expect it)\n                window.scrollTo({\n                    top: $difference.offset().top + targetPositionInDifference - (viewportHeight / 2),\n                    behavior: \"smooth\"\n                });\n            });\n        });\n    }\n\n    $('#jump-next-diff').click(function () {\n        if (!inputs || inputs.length === 0) return;\n\n        // Find the next change after current scroll position\n        var currentScrollPos = $(window).scrollTop();\n        var viewportHeight = $(window).height();\n        var currentCenter = currentScrollPos + (viewportHeight / 2);\n\n        // Add small buffer (50px) to jump past changes already near center\n        var searchFromPosition = currentCenter + 50;\n\n        var nextElement = null;\n        for (var i = 0; i < inputs.length; i++) {\n            var elementTop = $(inputs[i]).offset().top;\n            if (elementTop > searchFromPosition) {\n                nextElement = inputs[i];\n                break;\n            }\n        }\n\n        // If no element found ahead, wrap to first element\n        if (!nextElement) {\n            nextElement = inputs[0];\n        }\n\n        // Scroll to position the element at viewport center\n        var elementTop = $(nextElement).offset().top;\n        var targetScrollPos = elementTop - (viewportHeight / 2);\n\n        window.scrollTo({\n            top: targetScrollPos,\n            behavior: \"smooth\",\n        });\n    });\n\n    // Track current scroll position in visualizer\n    function updateVisualizerPosition() {\n        if (!$difference.length || visualizerResolutionCells === 0) return;\n\n        var scrollTop = $(window).scrollTop();\n        var viewportHeight = $(window).height();\n        var viewportCenter = scrollTop + (viewportHeight / 2);\n        var differenceTop = $difference.offset().top;\n        var differenceHeight = $difference[0].scrollHeight;\n        var positionInDifference = viewportCenter - differenceTop;\n\n        // Handle edge case: if we're at max scroll, show last cell\n        // This prevents shorter documents from never reaching 100%\n        var maxScrollTop = $(document).height() - viewportHeight;\n        var isAtBottom = scrollTop >= maxScrollTop - 10; // 10px tolerance\n\n        // Calculate which cell we're currently viewing\n        var currentCell;\n        if (isAtBottom) {\n            currentCell = visualizerResolutionCells - 1;\n        } else {\n            currentCell = Math.floor(positionInDifference / cellHeight);\n            currentCell = Math.max(0, Math.min(currentCell, visualizerResolutionCells - 1));\n        }\n\n        // Remove previous active marker and add to current cell\n        $visualizer.find('> div').removeClass('current-position');\n        $visualizer.find('> div').eq(currentCell).addClass('current-position');\n    }\n\n    // Recalculate cellHeight on window resize\n    function handleResize() {\n        if ($difference.length) {\n            var docHeight = $difference[0].scrollHeight;\n            cellHeight = docHeight / visualizerResolutionCells;\n            updateVisualizerPosition();\n        }\n    }\n\n    // Debounce scroll and resize events to reduce CPU usage\n    $(window).on('scroll', updateVisualizerPosition.debounce(5));\n    $(window).on('resize', handleResize.debounce(100));\n\n    // Initial scroll to specific line if requested\n    if (typeof initialScrollToLineNumber !== 'undefined' && initialScrollToLineNumber !== null && $difference.length) {\n        // Convert line number to text position and scroll to it\n        var diffText = $difference.text();\n        var lines = diffText.split('\\n');\n\n        if (initialScrollToLineNumber > 0 && initialScrollToLineNumber <= lines.length) {\n            // Calculate character position of the target line\n            var charPosition = 0;\n            for (var i = 0; i < initialScrollToLineNumber - 1; i++) {\n                charPosition += lines[i].length + 1; // +1 for newline\n            }\n\n            // Estimate vertical position based on average line height\n            var totalChars = diffText.length;\n            var totalHeight = $difference[0].scrollHeight;\n            var estimatedTop = (charPosition / totalChars) * totalHeight;\n\n            // Scroll to position with line at viewport center\n            var viewportHeight = $(window).height();\n            setTimeout(function() {\n                window.scrollTo({\n                    top: $difference.offset().top + estimatedTop - (viewportHeight / 2),\n                    behavior: \"smooth\"\n                });\n            }, 100); // Small delay to ensure page is fully loaded\n        }\n    }\n\n    // Initial position update\n    if ($difference.length && cellHeight) {\n        updateVisualizerPosition();\n    }\n\n    function changed() {\n        //$('#jump-next-diff').click();\n    }\n\n});\n\n"
  },
  {
    "path": "changedetectionio/static/js/flask-toast-bridge.js",
    "content": "/**\n * Flask Toast Bridge\n * Automatically converts Flask flash messages to toast notifications\n *\n * Maps Flask message categories to toast types:\n * - 'message' or 'info' -> info toast\n * - 'success' -> success toast\n * - 'error' or 'danger' -> error toast\n * - 'warning' -> warning toast\n */\n\n(function() {\n  'use strict';\n\n  document.addEventListener('DOMContentLoaded', function() {\n    // Find the Flask messages container\n    const messagesContainer = document.querySelector('ul.messages');\n\n    if (!messagesContainer) {\n      return;\n    }\n\n    // Get all flash messages\n    const messages = messagesContainer.querySelectorAll('li');\n\n    if (messages.length === 0) {\n      return;\n    }\n\n    let toastIndex = 0;\n\n    // Convert each message to a toast (except errors)\n    messages.forEach(function(messageEl) {\n      const text = messageEl.textContent.trim();\n      const category = getMessageCategory(messageEl);\n\n      // Skip error messages - they should stay in the page\n      if (category === 'error') {\n        return;\n      }\n\n      const toastType = mapCategoryToToastType(category);\n\n      // Stagger toast appearance for multiple messages\n      setTimeout(function() {\n        Toast[toastType](text, {\n          duration: 6000  // 6 seconds for Flask messages\n        });\n      }, toastIndex * 200);  // 200ms delay between each toast\n\n      toastIndex++;\n\n      // Hide this specific message element (not errors)\n      messageEl.style.display = 'none';\n    });\n  });\n\n  /**\n   * Extract message category from class names\n   */\n  function getMessageCategory(messageEl) {\n    const classes = messageEl.className.split(' ');\n\n    // Common Flask flash message categories\n    const categoryMap = {\n      'success': 'success',\n      'error': 'error',\n      'danger': 'error',\n      'warning': 'warning',\n      'info': 'info',\n      'message': 'info',\n      'notice': 'info'\n    };\n\n    for (let className of classes) {\n      if (categoryMap[className]) {\n        return categoryMap[className];\n      }\n    }\n\n    // Default to info if no category found\n    return 'info';\n  }\n\n  /**\n   * Map Flask category to Toast type\n   */\n  function mapCategoryToToastType(category) {\n    const typeMap = {\n      'success': 'success',\n      'error': 'error',\n      'warning': 'warning',\n      'info': 'info'\n    };\n\n    return typeMap[category] || 'info';\n  }\n\n})();\n"
  },
  {
    "path": "changedetectionio/static/js/global-settings.js",
    "content": "$(document).ready(function () {\n    $(\"#api-key\").hover(\n        function () {\n            $(\"#api-key-copy\").html('copy').fadeIn();\n        },\n        function () {\n            $(\"#api-key-copy\").hide();\n        }\n    ).click(function (e) {\n        $(\"#api-key-copy\").html('copied');\n        var range = document.createRange();\n        var n = $(\"#api-key\")[0];\n        range.selectNode(n);\n        window.getSelection().removeAllRanges();\n        window.getSelection().addRange(range);\n        document.execCommand(\"copy\");\n        window.getSelection().removeAllRanges();\n\n    });\n\n    $(\".toggle-show\").click(function (e) {\n        e.preventDefault();\n        let target = $(this).data('target');\n        $(target).toggle();\n    });\n\n    // Handle processor radio button changes - update body class\n    $('input[name=\"processor\"]').on('change', function() {\n        var selectedProcessor = $(this).val();\n\n        // Remove any existing processor-* classes from body\n        $('body').removeClass(function(index, className) {\n            return (className.match(/\\bprocessor-\\S+/g) || []).join(' ');\n        });\n\n        // Add the new processor class\n        $('body').addClass('processor-' + selectedProcessor);\n    });\n\n    // Time zone config related\n    $(\".local-time\").each(function (e) {\n        $(this).text(new Date($(this).data(\"utc\")).toLocaleString());\n    })\n\n    const timezoneInput = $('#application-scheduler_timezone_default');\n    if(timezoneInput.length) {\n        const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n        if (!timezoneInput.val().trim()) {\n            timezoneInput.val(timezone);\n            timezoneInput.after('<div class=\"timezone-message\">The timezone was set from your browser, <strong>be sure to press save!</strong></div>');\n        }\n    }\n\n});\n\n"
  },
  {
    "path": "changedetectionio/static/js/hamburger-menu.js",
    "content": "// Hamburger menu toggle functionality\n(function() {\n  'use strict';\n\n  document.addEventListener('DOMContentLoaded', function() {\n    const hamburgerToggle = document.getElementById('hamburger-toggle');\n    const mobileMenuDrawer = document.getElementById('mobile-menu-drawer');\n    const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');\n\n    if (!hamburgerToggle || !mobileMenuDrawer || !mobileMenuOverlay) {\n      return;\n    }\n\n    function openMenu() {\n      hamburgerToggle.classList.add('active');\n      mobileMenuDrawer.classList.add('active');\n      mobileMenuOverlay.classList.add('active');\n      document.body.style.overflow = 'hidden';\n    }\n\n    function closeMenu() {\n      hamburgerToggle.classList.remove('active');\n      mobileMenuDrawer.classList.remove('active');\n      mobileMenuOverlay.classList.remove('active');\n      document.body.style.overflow = '';\n    }\n\n    function toggleMenu() {\n      if (mobileMenuDrawer.classList.contains('active')) {\n        closeMenu();\n      } else {\n        openMenu();\n      }\n    }\n\n    // Toggle menu on hamburger click\n    hamburgerToggle.addEventListener('click', function(e) {\n      e.stopPropagation();\n      toggleMenu();\n    });\n\n    // Close menu when clicking overlay\n    mobileMenuOverlay.addEventListener('click', closeMenu);\n\n    // Close menu when clicking a menu item\n    const menuItems = mobileMenuDrawer.querySelectorAll('.mobile-menu-items a');\n    menuItems.forEach(function(item) {\n      item.addEventListener('click', closeMenu);\n    });\n\n    // Close menu on escape key\n    document.addEventListener('keydown', function(e) {\n      if (e.key === 'Escape' && mobileMenuDrawer.classList.contains('active')) {\n        closeMenu();\n      }\n    });\n\n    // Close menu when window is resized above mobile breakpoint\n    let resizeTimer;\n    window.addEventListener('resize', function() {\n      clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(function() {\n        if (window.innerWidth > 768 && mobileMenuDrawer.classList.contains('active')) {\n          closeMenu();\n        }\n      }, 250);\n    });\n  });\n})();\n"
  },
  {
    "path": "changedetectionio/static/js/language-selector.js",
    "content": "/**\n * Language selector modal functionality\n * Allows users to select their preferred language\n */\n\n$(document).ready(function() {\n  const $languageButton = $('.language-selector');\n  const $languageModal = $('#language-modal');\n  const $closeButton = $('#close-language-modal');\n\n  if (!$languageButton.length || !$languageModal.length) {\n    return;\n  }\n\n  // Open modal when language button is clicked\n  $languageButton.on('click', function(e) {\n    e.preventDefault();\n\n    // Update all language links to include current hash in the redirect parameter\n    const currentPath = window.location.pathname;\n    const currentHash = window.location.hash;\n\n    if (currentHash) {\n      const $languageOptions = $languageModal.find('.language-option');\n      $languageOptions.each(function() {\n        const $option = $(this);\n        const url = new URL($option.attr('href'), window.location.origin);\n        // Update the redirect parameter to include the hash\n        const redirectPath = currentPath + currentHash;\n        url.searchParams.set('redirect', redirectPath);\n        $option.attr('href', url.pathname + url.search + url.hash);\n      });\n    }\n\n    $languageModal[0].showModal();\n  });\n\n  // Close modal when cancel button is clicked\n  if ($closeButton.length) {\n    $closeButton.on('click', function() {\n      $languageModal[0].close();\n    });\n  }\n\n  // Close modal when clicking outside (on backdrop)\n  $languageModal.on('click', function(e) {\n    const rect = this.getBoundingClientRect();\n    if (\n      e.clientY < rect.top ||\n      e.clientY > rect.bottom ||\n      e.clientX < rect.left ||\n      e.clientX > rect.right\n    ) {\n      $languageModal[0].close();\n    }\n  });\n\n  // Close modal on Escape key\n  $languageModal.on('cancel', function(e) {\n    e.preventDefault();\n    $languageModal[0].close();\n  });\n\n  // Highlight current language\n  const currentLocale = $('html').attr('lang') || 'en';\n  const $languageOptions = $languageModal.find('.language-option');\n  $languageOptions.each(function() {\n    const $option = $(this);\n    if ($option.attr('data-locale') === currentLocale) {\n      $option.addClass('active');\n    }\n  });\n});\n"
  },
  {
    "path": "changedetectionio/static/js/modal.js",
    "content": "/**\n * Modern modal dialog system using HTML5 <dialog> element\n * Provides accessible, animated confirmation dialogs\n */\n\nconst ModalDialog = {\n  /**\n   * Show a confirmation dialog\n   * @param {Object} options - Configuration options\n   * @param {string} options.title - Dialog title\n   * @param {string} options.message - Dialog message (can include HTML)\n   * @param {string} options.type - Dialog type: 'danger', 'warning', or 'info' (default: 'info')\n   * @param {string} options.confirmText - Confirm button text (default: 'Confirm')\n   * @param {string} options.cancelText - Cancel button text (default: 'Cancel')\n   * @param {Function} options.onConfirm - Callback when confirmed\n   * @param {Function} options.onCancel - Callback when cancelled (optional)\n   * @returns {Promise} Resolves with true if confirmed, false if cancelled\n   */\n  confirm: function(options) {\n    return new Promise((resolve) => {\n      const defaults = {\n        title: 'Confirm Action',\n        message: 'Are you sure?',\n        type: 'info',\n        confirmText: 'Confirm',\n        cancelText: 'Cancel',\n        onConfirm: null,\n        onCancel: null\n      };\n\n      const config = { ...defaults, ...options };\n\n      // Icon mapping\n      const icons = {\n        danger: '⚠️',\n        warning: '⚠️',\n        info: 'ℹ️'\n      };\n\n      // Create dialog element\n      const dialog = document.createElement('dialog');\n      dialog.className = 'modal-dialog';\n      dialog.setAttribute('aria-labelledby', 'modal-title');\n      dialog.setAttribute('aria-describedby', 'modal-body');\n\n      // Build dialog content\n      dialog.innerHTML = `\n        <div class=\"modal-header\">\n          <span class=\"modal-icon ${config.type}\">${icons[config.type] || icons.info}</span>\n          <h2 class=\"modal-title\" id=\"modal-title\">${config.title}</h2>\n        </div>\n        <div class=\"modal-body\" id=\"modal-body\">\n          ${config.message}\n        </div>\n        <div class=\"modal-footer\">\n          <button type=\"button\" class=\"modal-btn-cancel pure-button\" data-action=\"cancel\">\n            ${config.cancelText}\n          </button>\n          <button type=\"button\" class=\"modal-btn-${config.type} pure-button\" data-action=\"confirm\">\n            ${config.confirmText}\n          </button>\n        </div>\n      `;\n\n      // Append to body\n      document.body.appendChild(dialog);\n\n      // Handle button clicks\n      const handleClose = (confirmed) => {\n        dialog.close();\n        setTimeout(() => {\n          dialog.remove();\n        }, 200);\n\n        if (confirmed && config.onConfirm) {\n          config.onConfirm();\n        } else if (!confirmed && config.onCancel) {\n          config.onCancel();\n        }\n\n        resolve(confirmed);\n      };\n\n      // Attach event listeners\n      dialog.querySelector('[data-action=\"confirm\"]').addEventListener('click', () => {\n        handleClose(true);\n      });\n\n      dialog.querySelector('[data-action=\"cancel\"]').addEventListener('click', () => {\n        handleClose(false);\n      });\n\n      // Handle Escape key\n      dialog.addEventListener('cancel', (e) => {\n        e.preventDefault();\n        handleClose(false);\n      });\n\n      // Handle backdrop click\n      dialog.addEventListener('click', (e) => {\n        const rect = dialog.getBoundingClientRect();\n        if (\n          e.clientY < rect.top ||\n          e.clientY > rect.bottom ||\n          e.clientX < rect.left ||\n          e.clientX > rect.right\n        ) {\n          handleClose(false);\n        }\n      });\n\n      // Show dialog\n      dialog.showModal();\n\n      // Focus confirm button for accessibility\n      setTimeout(() => {\n        dialog.querySelector('[data-action=\"confirm\"]').focus();\n      }, 100);\n    });\n  },\n\n  /**\n   * Helper method for delete confirmations\n   * @param {string} itemName - Name of the item being deleted\n   * @param {Function} onConfirm - Callback when confirmed\n   */\n  confirmDelete: function(itemName, onConfirm) {\n    return this.confirm({\n      title: 'Delete ' + itemName + '?',\n      message: `<p>Are you sure you want to delete <strong>${itemName}</strong>?</p><p>This action cannot be undone.</p>`,\n      type: 'danger',\n      confirmText: 'Delete',\n      cancelText: 'Cancel',\n      onConfirm: onConfirm\n    });\n  },\n\n  /**\n   * Helper method for unlink confirmations\n   * @param {string} itemName - Name of the item being unlinked\n   * @param {Function} onConfirm - Callback when confirmed\n   */\n  confirmUnlink: function(itemName, onConfirm) {\n    return this.confirm({\n      title: 'Unlink ' + itemName + '?',\n      message: `<p>Are you sure you want to unlink all watches from <strong>${itemName}</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>`,\n      type: 'warning',\n      confirmText: 'Unlink',\n      cancelText: 'Cancel',\n      onConfirm: onConfirm\n    });\n  }\n};\n\n// Make available globally\nwindow.ModalDialog = ModalDialog;\n\n/**\n * Auto-attach modal confirmations to links with data-requires-confirm attribute\n * Usage in HTML:\n * <a href=\"/delete\"\n *    data-requires-confirm\n *    data-confirm-type=\"danger\"\n *    data-confirm-title=\"Delete Item?\"\n *    data-confirm-message=\"Are you sure?\"\n *    data-confirm-button=\"Delete\">\n */\n$(document).ready(function() {\n  $(document).on('click', 'a[data-requires-confirm], button[data-requires-confirm]', function(e) {\n    e.preventDefault();\n    const $element = $(this);\n    const url = $element.attr('href');\n\n    const config = {\n      type: $element.data('confirm-type') || 'danger',\n      title: $element.data('confirm-title') || 'Confirm Action',\n      message: $element.data('confirm-message') || '<p>Are you sure you want to proceed?</p>',\n      confirmText: $element.data('confirm-button') || 'Confirm',\n      cancelText: $element.data('cancel-button') || 'Cancel',\n      onConfirm: function() {\n        // If it's a link, navigate to the URL\n        if ($element.is('a')) {\n          window.location.href = url;\n        }\n        // If it's a button in a form, submit the form\n        else if ($element.is('button')) {\n          // Use requestSubmit() to include the button's name/value in the form data\n          $element.closest('form')[0].requestSubmit($element[0]);\n        }\n      }\n    };\n\n    ModalDialog.confirm(config);\n  });\n});\n"
  },
  {
    "path": "changedetectionio/static/js/notifications.js",
    "content": "$(document).ready(function () {\n\n    $('#add-email-helper').click(function (e) {\n        e.preventDefault();\n        email = prompt(\"Destination email\");\n        if (email) {\n            var n = $(\".notification-urls\");\n            var p = email_notification_prefix;\n            $(n).val($.trim($(n).val()) + \"\\n\" + email_notification_prefix + email);\n        }\n    });\n\n    $('#send-test-notification').click(function (e) {\n        e.preventDefault();\n\n        data = {\n            notification_urls: $('textarea.notification-urls').val(),\n            notification_title: $('input.notification-title').val(),\n            notification_body: $('textarea.notification-body').val(),\n            notification_format: $('select.notification-format').val(),\n            tags: $('#tags').val(),\n            window_url: window.location.href,\n        }\n\n        $('.notifications-wrapper .spinner').fadeIn();\n        $('#notification-test-log').show();\n        $.ajax({\n            type: \"POST\",\n            url: notification_base_url,\n            data: data,\n            statusCode: {\n                400: function (data) {\n                    $(\"#notification-test-log>span\").text(data.responseText);\n                },\n            }\n        }).done(function (data) {\n            $(\"#notification-test-log>span\").text(data);\n        }).fail(function (jqXHR, textStatus, errorThrown) {\n            // Handle connection refused or other errors\n            if (textStatus === \"error\" && errorThrown === \"\") {\n                console.error(\"Connection refused or server unreachable\");\n                $(\"#notification-test-log>span\").text(\"Error: Connection refused or server is unreachable.\");\n            } else {\n                console.error(\"Error:\", textStatus, errorThrown);\n                $(\"#notification-test-log>span\").text(\"An error occurred: \" + textStatus);\n            }\n        }).always(function () {\n            $('.notifications-wrapper .spinner').hide();\n        })\n    });\n});\n\n"
  },
  {
    "path": "changedetectionio/static/js/plugins.js",
    "content": "(function ($) {\n    /**\n     * debounce\n     * @param {integer} milliseconds This param indicates the number of milliseconds\n     *     to wait after the last call before calling the original function.\n     * @param {object} What \"this\" refers to in the returned function.\n     * @return {function} This returns a function that when called will wait the\n     *     indicated number of milliseconds after the last call before\n     *     calling the original function.\n     */\n    Function.prototype.debounce = function (milliseconds, context) {\n        var baseFunction = this,\n            timer = null,\n            wait = milliseconds;\n\n        return function () {\n            var self = context || this,\n                args = arguments;\n\n            function complete() {\n                baseFunction.apply(self, args);\n                timer = null;\n            }\n\n            if (timer) {\n                clearTimeout(timer);\n            }\n\n            timer = setTimeout(complete, wait);\n        };\n    };\n\n    /**\n     * throttle\n     * @param {integer} milliseconds This param indicates the number of milliseconds\n     *     to wait between calls before calling the original function.\n     * @param {object} What \"this\" refers to in the returned function.\n     * @return {function} This returns a function that when called will wait the\n     *     indicated number of milliseconds between calls before\n     *     calling the original function.\n     */\n    Function.prototype.throttle = function (milliseconds, context) {\n        var baseFunction = this,\n            lastEventTimestamp = null,\n            limit = milliseconds;\n\n        return function () {\n            var self = context || this,\n                args = arguments,\n                now = Date.now();\n\n            if (!lastEventTimestamp || now - lastEventTimestamp >= limit) {\n                lastEventTimestamp = now;\n                baseFunction.apply(self, args);\n            }\n        };\n    };\n\n    $.fn.highlightLines = function (configurations) {\n        return this.each(function () {\n            const $pre = $(this);\n            const textContent = $pre.text();\n            const lines = textContent.split(/\\r?\\n/); // Handles both \\n and \\r\\n line endings\n\n            // Build a map of line numbers to their configuration index\n            const lineConfigIndex = {};\n\n            configurations.forEach((config, index) =>\n                config.lines.forEach(lineNumber => lineConfigIndex[lineNumber] = index)\n            );\n\n            // Function to escape HTML characters\n            function escapeHtml(text) {\n                return text.replace(/[&<>\"'`=\\/]/g, function (s) {\n                    return \"&#\" + s.charCodeAt(0) + \";\";\n                });\n            }\n\n            // Process each line\n            const processedLines = lines.map((line, index) => {\n                const lineNumber = index + 1; // Line numbers start at 1\n                const escapedLine = escapeHtml(line);\n                const configIndex = lineConfigIndex[lineNumber];\n\n                if (configIndex !== undefined) {\n                    const config = configurations[configIndex];\n                    // Wrap the line in a span with inline style\n                    return `<span title=\"${config.title}\" style=\"background-color: ${config.color}\">${escapedLine}</span>`;\n                } else {\n                    return escapedLine;\n                }\n            });\n\n            // Join the lines back together\n            const newContent = processedLines.join('\\n');\n\n            // Set the new content as HTML\n            $pre.html(newContent);\n        });\n    };\n\n    $.fn.miniTabs = function (tabsConfig, options) {\n        const settings = {\n            tabClass: 'minitab',\n            tabsContainerClass: 'minitabs',\n            activeClass: 'active',\n            ...(options || {})\n        };\n\n        return this.each(function () {\n            const $wrapper = $(this);\n            const $contents = $wrapper.find('div[id]').hide();\n            const $tabsContainer = $('<div>', {class: settings.tabsContainerClass}).prependTo($wrapper);\n\n            // Generate tabs\n            Object.entries(tabsConfig).forEach(([tabTitle, contentSelector], index) => {\n                const $content = $wrapper.find(contentSelector);\n                if (index === 0) $content.show(); // Show first content by default\n\n                $('<a>', {\n                    class: `${settings.tabClass}${index === 0 ? ` ${settings.activeClass}` : ''}`,\n                    text: tabTitle,\n                    'data-target': contentSelector\n                }).appendTo($tabsContainer);\n            });\n\n            // Tab click event\n            $tabsContainer.on('click', `.${settings.tabClass}`, function (e) {\n                e.preventDefault();\n                const $tab = $(this);\n                const target = $tab.data('target');\n\n                // Update active tab\n                $tabsContainer.find(`.${settings.tabClass}`).removeClass(settings.activeClass);\n                $tab.addClass(settings.activeClass);\n\n                // Show/hide content\n                $contents.hide();\n                $wrapper.find(target).show();\n            });\n        });\n    };\n\n    // Object to store ongoing requests by namespace\n    const requests = {};\n\n    $.abortiveSingularAjax = function (options) {\n        const namespace = options.namespace || 'default';\n\n        // Abort the current request in this namespace if it's still ongoing\n        if (requests[namespace]) {\n            requests[namespace].abort();\n        }\n\n        // Start a new AJAX request and store its reference in the correct namespace\n        requests[namespace] = $.ajax(options);\n\n        // Return the current request in case it's needed\n        return requests[namespace];\n    };\n\n})(jQuery);\n\n\n\nfunction toggleOpacity(checkboxSelector, fieldSelector, inverted) {\n    const checkbox = document.querySelector(checkboxSelector);\n    const fields = document.querySelectorAll(fieldSelector);\n\n    function updateOpacity() {\n        const opacityValue = !checkbox.checked ? (inverted ? 0.6 : 1) : (inverted ? 1 : 0.6);\n        fields.forEach(field => {\n            field.style.opacity = opacityValue;\n        });\n    }\n\n    // Initial setup\n    updateOpacity();\n    checkbox.addEventListener('change', updateOpacity);\n}\n\nfunction toggleVisibility(checkboxSelector, fieldSelector, inverted) {\n    const checkbox = document.querySelector(checkboxSelector);\n    const fields = document.querySelectorAll(fieldSelector);\n\n    function updateOpacity() {\n        const opacityValue = !checkbox.checked ? (inverted ? 'none' : 'block') : (inverted ? 'block' : 'none');\n        fields.forEach(field => {\n            field.style.display = opacityValue;\n        });\n    }\n\n    // Initial setup\n    updateOpacity();\n    checkbox.addEventListener('change', updateOpacity);\n}\n"
  },
  {
    "path": "changedetectionio/static/js/preview.js",
    "content": "function redirectToVersion(version) {\n    var currentUrl = window.location.href.split('?')[0]; // Base URL without query parameters\n    var anchor = '';\n\n    // Check if there is an anchor\n    if (currentUrl.indexOf('#') !== -1) {\n        anchor = currentUrl.substring(currentUrl.indexOf('#'));\n        currentUrl = currentUrl.substring(0, currentUrl.indexOf('#'));\n    }\n\n    window.location.href = currentUrl + '?version=' + version + anchor;\n}\n\nfunction setupDateWidget() {\n    $(document).on('keydown', function (event) {\n        var $selectElement = $('#preview-version');\n        var $selectedOption = $selectElement.find('option:selected');\n\n        if ($selectedOption.length) {\n            if (event.key === 'ArrowLeft' && $selectedOption.prev().length) {\n                redirectToVersion($selectedOption.prev().val());\n            } else if (event.key === 'ArrowRight' && $selectedOption.next().length) {\n                redirectToVersion($selectedOption.next().val());\n            }\n        }\n    });\n\n    $('#preview-version').on('change', function () {\n        redirectToVersion($(this).val());\n    });\n\n    var $selectedOption = $('#preview-version option:selected');\n\n    if ($selectedOption.length) {\n        var $prevOption = $selectedOption.prev();\n        var $nextOption = $selectedOption.next();\n\n        if ($prevOption.length) {\n            $('#btn-previous').attr('href', '?version=' + $prevOption.val());\n        } else {\n            $('#btn-previous').remove();\n        }\n\n        if ($nextOption.length) {\n            $('#btn-next').attr('href', '?version=' + $nextOption.val());\n        } else {\n            $('#btn-next').remove();\n        }\n    }\n}\n\n$(document).ready(function () {\n    if ($('#preview-version').length) {\n        setupDateWidget();\n    }\n    $('pre#difference').highlightLines([\n        {\n            'color': 'var(--highlight-trigger-text-bg-color)',\n            'lines': triggered_line_numbers,\n            'title': \"Triggers a change if this text appears, AND something changed in the document.\"\n        },\n        {\n            'color': 'var(--highlight-ignored-text-bg-color)',\n            'lines': ignored_line_numbers,\n            'title': \"Ignored for calculating changes, but still shown.\"\n        },\n        {\n            'color': 'var(--highlight-blocked-text-bg-color)',\n            'lines': blocked_line_numbers,\n            'title': \"No change-detection will occur because this text exists.\"\n        }\n    ]);\n});\n"
  },
  {
    "path": "changedetectionio/static/js/realtime.js",
    "content": "// Socket.IO client-side integration for changedetection.io\n\n$(document).ready(function () {\n\n    function reapplyTableStripes() {\n        $('.watch-table tbody tr').each(function(index) {\n            $(this).removeClass('pure-table-odd pure-table-even');\n            $(this).addClass(index % 2 === 0 ? 'pure-table-odd' : 'pure-table-even');\n        });\n    }\n\n    function bindSocketHandlerButtonsEvents(socket) {\n        $('.ajax-op').on('click.socketHandlerNamespace', function (e) {\n            e.preventDefault();\n            const op = $(this).data('op');\n            const uuid = $(this).closest('tr').data('watch-uuid');\n            \n            console.log(`Socket.IO: Sending watch operation '${op}' for UUID ${uuid}`);\n            \n            // Emit the operation via Socket.IO\n            socket.emit('watch_operation', {\n                'op': op,\n                'uuid': uuid\n            });\n            \n            return false;\n        });\n\n\n        $('#checkbox-operations button').on('click.socketHandlerNamespace', function (e) {\n            e.preventDefault();\n            const $button = $(this);\n            const op = $button.val();\n            const checkedUuids = $('input[name=\"uuids\"]:checked').map(function () {\n                return this.value.trim();\n            }).get();\n\n            // Check if this button requires confirmation\n            console.log('Button clicked, op:', op, 'requires-confirm:', $button.is('[data-requires-confirm]'));\n            if ($button.is('[data-requires-confirm]')) {\n                console.log('Showing modal confirmation for operation:', op);\n                const config = {\n                    type: $button.data('confirm-type') || 'danger',\n                    title: $button.data('confirm-title') || 'Confirm Action',\n                    message: $button.data('confirm-message') || '<p>Are you sure you want to proceed?</p>',\n                    confirmText: $button.data('confirm-button') || 'Confirm',\n                    cancelText: $button.data('cancel-button') || 'Cancel',\n                    onConfirm: function() {\n                        console.log(`Socket.IO: Sending watch operation '${op}' for UUIDs:`, checkedUuids);\n                        socket.emit('checkbox-operation', {\n                            op: op,\n                            uuids: checkedUuids,\n                            extra_data: $('#op_extradata').val()\n                        });\n                        $('input[name=\"uuids\"]:checked').prop('checked', false);\n                        $('#check-all:checked').prop('checked', false);\n                    }\n                };\n                ModalDialog.confirm(config);\n            } else {\n                console.log(`Socket.IO: Sending watch operation '${op}' for UUIDs:`, checkedUuids);\n                socket.emit('checkbox-operation', {\n                    op: op,\n                    uuids: checkedUuids,\n                    extra_data: $('#op_extradata').val()\n                });\n                $('input[name=\"uuids\"]:checked').prop('checked', false);\n                $('#check-all:checked').prop('checked', false);\n            }\n\n            return false;\n        });\n\n    }\n\n\n    // Cache DOM elements for performance\n    const queueBubble = document.getElementById('queue-bubble');\n    const queueSizePagerInfoText = document.getElementById('queue-size-int');\n    // Only try to connect if authentication isn't required or user is authenticated\n    // The 'is_authenticated' variable will be set in the template\n    if (typeof is_authenticated !== 'undefined' ? is_authenticated : true) {\n        // Try to create the socket connection to the SocketIO server - if it fails, the site will still work normally\n        try {\n            // Connect to Socket.IO on the same host/port, with path from template\n            const socket = io({\n                path: socketio_url,  // This will be the path prefix like \"/app/socket.io\" from the template\n                transports: ['websocket', 'polling'],\n                reconnectionDelay: 3000,\n                reconnectionAttempts: 25\n            });\n\n            // Connection status logging\n            socket.on('connect', function () {\n                $('#realtime-conn-error').hide();\n                console.log('Socket.IO connected with path:', socketio_url);\n                console.log('Socket transport:', socket.io.engine.transport.name);\n                bindSocketHandlerButtonsEvents(socket);\n            });\n\n            socket.on('connect_error', function(error) {\n                console.error('Socket.IO connection error:', error);\n            });\n\n            socket.on('connect_timeout', function() {\n                console.error('Socket.IO connection timeout');\n            });\n\n            socket.on('error', function(error) {\n                console.error('Socket.IO error:', error);\n            });\n\n            socket.on('disconnect', function (reason) {\n                console.log('Socket.IO disconnected, reason:', reason);\n                $('.ajax-op').off('.socketHandlerNamespace');\n                $('#realtime-conn-error').show();\n            });\n\n            // Tell the server we're leaving cleanly so it can release the connection\n            // immediately rather than waiting for a timeout.\n            // Note: this only fires for voluntary closes (tab/window close, navigation away).\n            // Hard kills, crashes and network drops will still timeout normally on the server.\n            window.addEventListener('beforeunload', function () {\n                socket.disconnect();\n            });\n\n            socket.on('queue_size', function (data) {\n                console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);\n                if(queueSizePagerInfoText) {\n                    queueSizePagerInfoText.textContent = parseInt(data.q_length).toLocaleString() || 'None';\n                }\n                document.body.classList.toggle('has-queue', parseInt(data.q_length) > 0);\n\n                // Update queue bubble in action sidebar\n                //if (queueBubble) {\n                if (0) {\n                    const count = parseInt(data.q_length) || 0;\n                    const oldCount = parseInt(queueBubble.getAttribute('data-count')) || 0;\n\n                    if (count > 0) {\n                        // Format number according to browser locale\n                        const formatter = new Intl.NumberFormat(navigator.language);\n                        queueBubble.textContent = formatter.format(count);\n                        queueBubble.setAttribute('data-count', count);\n                        queueBubble.classList.add('visible');\n\n                        // Add large-number class for numbers > 999\n                        if (count > 999) {\n                            queueBubble.classList.add('large-number');\n                        } else {\n                            queueBubble.classList.remove('large-number');\n                        }\n\n                        // Pulse animation if count changed\n                        if (count !== oldCount) {\n                            queueBubble.classList.remove('pulse');\n                            // Force reflow to restart animation\n                            void queueBubble.offsetWidth;\n                            queueBubble.classList.add('pulse');\n                        }\n                    } else {\n                        // Hide bubble when queue is empty\n                        queueBubble.classList.remove('visible', 'pulse', 'large-number');\n                        queueBubble.setAttribute('data-count', '0');\n                    }\n                }\n            })\n\n            // Listen for operation results\n            socket.on('operation_result', function (data) {\n                if (data.success) {\n                    console.log(`Socket.IO: Operation '${data.operation}' completed successfully for UUID ${data.uuid}`);\n                } else {\n                    console.error(`Socket.IO: Operation failed: ${data.error}`);\n                    alert(\"There was a problem processing the request: \" + data.error);\n                }\n            });\n\n            socket.on('watch_small_status_comment', function (data) {\n                console.log(`Socket.IO: Operation  watch_small_status_comment'${data.uuid}' status ${data.status}`);\n                $('tr[data-watch-uuid=\"' + data.uuid + '\"] td.last-checked .status-text').html(\"&nbsp;\").text(data.status);\n            });\n\n            socket.on('notification_event', function (data) {\n                console.log(`Stub handler for notification_event ${data.watch_uuid}`)\n            });\n\n            socket.on('watch_deleted', function (data) {\n                $('tr[data-watch-uuid=\"' + data.uuid + '\"] td').fadeOut(500, function () {\n                    $(this).closest('tr').remove();\n                    reapplyTableStripes();\n                });\n            });\n\n            // So that the favicon is only updated when the server has written the scraped favicon to disk.\n            socket.on('watch_bumped_favicon', function (watch) {\n                const $watchRow = $(`tr[data-watch-uuid=\"${watch.uuid}\"]`);\n                if ($watchRow.length) {\n                    $watchRow.addClass('has-favicon');\n                    // Because the event could be emitted from a process that is outside the app context, url_for() might not work.\n                    // Lets use url_for at template generation time to give us a PLACEHOLDER instead\n                    let favicon_url = favicon_baseURL.replace('/PLACEHOLDER', `/${watch.uuid}?cache=${watch.event_timestamp}`);\n                    console.log(`Setting favicon for UUID - ${watch.uuid} - ${favicon_url}`);\n                    $('img.favicon', $watchRow).attr('src', favicon_url);\n                }\n            })\n\n            socket.on('general_stats_update', function (general_stats) {\n                // Tabs at bottom of list\n                $('#watch-table-wrapper').toggleClass(\"has-unread-changes\", general_stats.unread_changes_count !==0)\n                $('#watch-table-wrapper').toggleClass(\"has-error\", general_stats.count_errors !== 0)\n                $('#post-list-with-errors a').text(`With errors (${ new Intl.NumberFormat(navigator.language).format(general_stats.count_errors) })`);\n                $('#unread-tab-counter').text(new Intl.NumberFormat(navigator.language).format(general_stats.unread_changes_count));\n            });\n\n            socket.on('watch_update', function (data) {\n                const watch = data.watch;\n\n                // Updating watch table rows\n                const $watchRow = $('tr[data-watch-uuid=\"' + watch.uuid + '\"]');\n                console.log('Found watch row elements:', $watchRow.length);\n\n                if ($watchRow.length) {\n                    $($watchRow).toggleClass('checking-now', watch.checking_now);\n                    $($watchRow).toggleClass('queued', watch.queued);\n                    $($watchRow).toggleClass('unviewed', watch.unviewed);\n                    $($watchRow).toggleClass('has-error', watch.has_error);\n                    $($watchRow).toggleClass('has-favicon', watch.has_favicon);\n                    $($watchRow).toggleClass('notification_muted', watch.notification_muted);\n                    $($watchRow).toggleClass('paused', watch.paused);\n                    $($watchRow).toggleClass('single-history', watch.history_n === 1);\n                    $($watchRow).toggleClass('multiple-history', watch.history_n >= 2);\n\n                    $('td.title-col .error-text', $watchRow).html(watch.error_text)\n                    $('td.last-changed', $watchRow).text(watch.last_changed_text)\n                    $('td.last-checked .innertext', $watchRow).text(watch.last_checked_text)\n                    $('td.last-checked', $watchRow).data('timestamp', watch.last_checked).data('fetchduration', watch.fetch_time);\n                    $('td.last-checked', $watchRow).data('eta_complete', watch.last_checked + watch.fetch_time);\n\n                    console.log('Updated UI for watch:', watch.uuid);\n                }\n                $('body').toggleClass('checking-now', watch.checking_now && window.location.href.includes(watch.uuid));\n            });\n\n        } catch (e) {\n            // If Socket.IO fails to initialize, just log it and continue\n            console.log('Socket.IO initialization error:', e);\n        }\n    }\n});"
  },
  {
    "path": "changedetectionio/static/js/recheck-proxy.js",
    "content": "$(function () {\n    /* add container before each proxy location to show status */\n    var isActive = false;\n\n    function setup_html_widget() {\n        var option_li = $('.fetch-backend-proxy li').filter(function () {\n            return $(\"input\", this)[0].value.length > 0;\n        });\n        $(option_li).prepend('<div class=\"proxy-status\"></div>');\n        $(option_li).append('<div class=\"proxy-timing\"></div><div class=\"proxy-check-details\"></div>');\n    }\n\n    function set_proxy_check_status(proxy_key, state) {\n        // select input by value name\n        const proxy_li = $('input[value=\"' + proxy_key + '\" ]').parent();\n        if (state['status'] === 'RUNNING') {\n            $('.proxy-status', proxy_li).html('<span class=\"spinner\"></span>');\n        }\n        if (state['status'] === 'OK') {\n            $('.proxy-status', proxy_li).html('<span style=\"color: green; font-weight: bold\" >OK</span>');\n            $('.proxy-check-details', proxy_li).html(state['text']);\n        }\n        if (state['status'] === 'ERROR' || state['status'] === 'ERROR OTHER') {\n            $('.proxy-status', proxy_li).html('<span style=\"color: red; font-weight: bold\" >X</span>');\n            $('.proxy-check-details', proxy_li).html(state['text']);\n        }\n        $('.proxy-timing', proxy_li).html(state['time']);\n    }\n\n\n    function pollServer() {\n        if (isActive) {\n            window.setTimeout(function () {\n                $.ajax({\n                    url: proxy_recheck_status_url,\n                    success: function (data) {\n                        var all_done = true;\n                        $.each(data, function (proxy_key, state) {\n                            set_proxy_check_status(proxy_key, state);\n                            if (state['status'] === 'RUNNING') {\n                                all_done = false;\n                            }\n                        });\n\n                        if (all_done) {\n                            console.log(\"Shutting down poller, all done.\")\n                            isActive = false;\n                        } else {\n                            pollServer();\n                        }\n                    },\n                    error: function () {\n                        //ERROR HANDLING\n                        pollServer();\n                    }\n                });\n            }, 2000);\n        }\n    }\n\n    $('#check-all-proxies').click(function (e) {\n\n        e.preventDefault()\n\n        if (!$('body').hasClass('proxy-check-active')) {\n            setup_html_widget();\n            $('body').addClass('proxy-check-active');\n        }\n\n        $('.proxy-check-details').html('');\n        $('.proxy-status').html('<span class=\"spinner\"></span>').fadeIn();\n        $('.proxy-timing').html('');\n\n        // Request start, needs CSRF?\n        $.ajax({\n            type: \"GET\",\n            url: recheck_proxy_start_url,\n        }).done(function (data) {\n            $.each(data, function (proxy_key, state) {\n                set_proxy_check_status(proxy_key, state['status'])\n            });\n            isActive = true;\n            pollServer();\n\n        }).fail(function (data) {\n            console.log(data);\n            alert('There was an error communicating with the server.');\n        });\n\n    });\n\n});\n\n"
  },
  {
    "path": "changedetectionio/static/js/scheduler.js",
    "content": "function getTimeInTimezone(timezone) {\n    const now = new Date();\n    const options = {\n        timeZone: timezone,\n        weekday: 'long',\n        year: 'numeric',\n        hour12: false,\n        month: '2-digit',\n        day: '2-digit',\n        hour: '2-digit',\n        minute: '2-digit',\n        second: '2-digit',\n    };\n\n    const formatter = new Intl.DateTimeFormat('en-US', options);\n    return formatter.format(now);\n}\n\n$(document).ready(function () {\n\n    let exceedsLimit = false;\n    const warning_text = $(\"#timespan-warning\")\n    const timezone_text_widget = $(\"input[id*='time_schedule_limit-timezone']\")\n\n    toggleVisibility('#time_schedule_limit-enabled, #requests-time_schedule_limit-enabled', '#schedule-day-limits-wrapper', true)\n\n    setInterval(() => {\n        let success = true;\n        try {\n            // Show the current local time according to either placeholder or entered TZ name\n            if (timezone_text_widget.val().length) {\n                $('#local-time-in-tz').text(getTimeInTimezone(timezone_text_widget.val()));\n            } else {\n                // So maybe use what is in the placeholder (which will be the default settings)\n                $('#local-time-in-tz').text(getTimeInTimezone(timezone_text_widget.attr('placeholder')));\n            }\n        } catch (error) {\n            success = false;\n            $('#local-time-in-tz').text(\"\");\n            console.error(timezone_text_widget.val())\n        }\n\n        $(timezone_text_widget).toggleClass('error', !success);\n\n    }, 500);\n\n    $('#schedule-day-limits-wrapper').on('change click blur', 'input, checkbox, select', function() {\n\n        let allOk = true;\n\n        // Controls setting the warning that the time could overlap into the next day\n        $(\"li.day-schedule\").each(function () {\n            const $schedule = $(this);\n            const $checkbox = $schedule.find(\"input[type='checkbox']\");\n\n            if ($checkbox.is(\":checked\")) {\n                const timeValue = $schedule.find(\"input[type='time']\").val();\n                const durationHours = parseInt($schedule.find(\"select[name*='-duration-hours']\").val(), 10) || 0;\n                const durationMinutes = parseInt($schedule.find(\"select[name*='-duration-minutes']\").val(), 10) || 0;\n\n                if (timeValue) {\n                    const [startHours, startMinutes] = timeValue.split(\":\").map(Number);\n                    const totalMinutes = (startHours * 60 + startMinutes) + (durationHours * 60 + durationMinutes);\n\n                    exceedsLimit = totalMinutes > 1440\n                    if (exceedsLimit) {\n                        allOk = false\n                    }\n                    // Set the row/day-of-week highlight\n                    $schedule.toggleClass(\"warning\", exceedsLimit);\n                }\n            } else {\n                $schedule.toggleClass(\"warning\", false);\n            }\n        });\n\n        warning_text.toggle(!allOk)\n    });\n\n    $('table[id*=\"time_schedule_limit-saturday\"], table[id*=\"time_schedule_limit-sunday\"]').addClass(\"weekend-day\")\n\n    // Presets [weekend] [business hours] etc\n    $(document).on('click', '[data-template].set-schedule', function () {\n        // Get the value of the 'data-template' attribute\n        switch ($(this).attr('data-template')) {\n            case 'business-hours':\n                $('.day-schedule table:not(.weekend-day) input[type=\"time\"]').val('09:00')\n                $('.day-schedule table:not(.weekend-day) select[id*=\"-duration-hours\"]').val('8');\n                $('.day-schedule table:not(.weekend-day) select[id*=\"-duration-minutes\"]').val('0');\n                $('.day-schedule input[id*=\"-enabled\"]').prop('checked', true);\n                $('.day-schedule .weekend-day input[id*=\"-enabled\"]').prop('checked', false);\n                break;\n            case 'weekend':\n                $('.day-schedule .weekend-day input[type=\"time\"][id$=\"start-time\"]').val('00:00')\n                $('.day-schedule .weekend-day select[id*=\"-duration-hours\"]').val('24');\n                $('.day-schedule .weekend-day select[id*=\"-duration-minutes\"]').val('0');\n                $('.day-schedule input[id*=\"-enabled\"]').prop('checked', false);\n                $('.day-schedule .weekend-day input[id*=\"-enabled\"]').prop('checked', true);\n                break;\n            case 'reset':\n\n                $('.day-schedule input[type=\"time\"]').val('00:00')\n                $('.day-schedule select[id*=\"-duration-hours\"]').val('24');\n                $('.day-schedule select[id*=\"-duration-minutes\"]').val('0');\n                $('.day-schedule input[id*=\"-enabled\"]').prop('checked', true);\n                break;\n        }\n    });\n});\n"
  },
  {
    "path": "changedetectionio/static/js/search-modal.js",
    "content": "// Search modal functionality\n(function() {\n  'use strict';\n\n  document.addEventListener('DOMContentLoaded', function() {\n    const searchModal = document.getElementById('search-modal');\n    const openSearchButton = document.getElementById('open-search-modal');\n    const closeSearchButton = document.getElementById('close-search-modal');\n    const searchForm = document.getElementById('search-form');\n    const searchInput = document.getElementById('search-modal-input');\n\n    if (!searchModal || !openSearchButton) {\n      return;\n    }\n\n    // Open modal\n    function openSearchModal() {\n      searchModal.showModal();\n      // Focus the input after a small delay to ensure modal is rendered\n      setTimeout(function() {\n        if (searchInput) {\n          searchInput.focus();\n        }\n      }, 100);\n    }\n\n    // Close modal\n    function closeSearchModal() {\n      searchModal.close();\n      if (searchInput) {\n        searchInput.value = '';\n      }\n    }\n\n    // Open search modal on button click\n    openSearchButton.addEventListener('click', openSearchModal);\n\n    // Close modal on cancel button\n    if (closeSearchButton) {\n      closeSearchButton.addEventListener('click', closeSearchModal);\n    }\n\n    // Close modal on escape key (native behavior for dialog)\n    searchModal.addEventListener('cancel', function(e) {\n      if (searchInput) {\n        searchInput.value = '';\n      }\n    });\n\n    // Close modal when clicking the backdrop\n    searchModal.addEventListener('click', function(e) {\n      const rect = searchModal.getBoundingClientRect();\n      const isInDialog = (\n        rect.top <= e.clientY &&\n        e.clientY <= rect.top + rect.height &&\n        rect.left <= e.clientX &&\n        e.clientX <= rect.left + rect.width\n      );\n      if (!isInDialog) {\n        closeSearchModal();\n      }\n    });\n\n    // Handle Alt+S keyboard shortcut\n    document.addEventListener('keydown', function(e) {\n      if (e.altKey && e.key.toLowerCase() === 's') {\n        e.preventDefault();\n        openSearchModal();\n      }\n    });\n\n    // Handle Enter key in search input\n    if (searchInput) {\n      searchInput.addEventListener('keydown', function(e) {\n        if (e.key === 'Enter') {\n          e.preventDefault();\n          if (searchForm) {\n            // Trigger form submission programmatically\n            searchForm.dispatchEvent(new Event('submit'));\n          }\n        }\n      });\n    }\n\n    // Handle form submission\n    if (searchForm) {\n      searchForm.addEventListener('submit', function(e) {\n        e.preventDefault();\n\n        // Get form data\n        const formData = new FormData(searchForm);\n        const searchQuery = formData.get('q');\n        const tags = formData.get('tags');\n\n        // Build URL\n        const params = new URLSearchParams();\n        if (searchQuery) {\n          params.append('q', searchQuery);\n        }\n        if (tags) {\n          params.append('tags', tags);\n        }\n\n        // Navigate to search results (always redirect to watchlist home)\n        // Use base_path if available (for sub-path deployments like /enlighten-richerx)\n        const basePath = typeof base_path !== 'undefined' ? base_path : '';\n        window.location.href = basePath + '/?' + params.toString();\n      });\n    }\n  });\n})();\n"
  },
  {
    "path": "changedetectionio/static/js/snippet-to-image.js",
    "content": "/**\n * snippet-to-image.js\n * Converts selected diff content to a shareable JPEG image with metadata\n */\n\n// Constants\nconst IMAGE_PADDING = 5;\nconst JPEG_QUALITY = 0.95;\nconst CANVAS_SCALE = 1;\nconst RENDER_DELAY_MS = 50;\n\n/**\n * Utility: Get the target URL from global watch_url or fallback to current URL\n */\nfunction getTargetUrl() {\n    return (typeof watch_url !== 'undefined' && watch_url) ? watch_url : window.location.href;\n}\n\n/**\n * Utility: Get formatted current date with timezone\n */\nfunction getFormattedDate() {\n    return new Date().toLocaleString(undefined, {\n        year: 'numeric',\n        month: 'long',\n        day: 'numeric',\n        hour: '2-digit',\n        minute: '2-digit',\n        second: '2-digit',\n        timeZoneName: 'short'\n    });\n}\n\n/**\n * Utility: Get version comparison info from the diff selectors\n */\nfunction getVersionInfo() {\n    const fromSelect = document.getElementById('diff-from-version');\n    const toSelect = document.getElementById('diff-to-version');\n\n    if (!fromSelect || !toSelect) {\n        return '';\n    }\n\n    const fromOption = fromSelect.options[fromSelect.selectedIndex];\n    const toOption = toSelect.options[toSelect.selectedIndex];\n    const fromLabel = fromOption ? (fromOption.getAttribute('label') || fromOption.text) : 'Unknown';\n    const toLabel = toOption ? (toOption.getAttribute('label') || toOption.text) : 'Unknown';\n\n    return `Change comparison from <strong>${fromLabel}</strong> to <strong>${toLabel}</strong>`;\n}\n\n/**\n * Helper: Find text node containing newline in a given direction\n */\nfunction findTextNodeWithNewline(node, searchBackwards = false) {\n    if (node.nodeType === Node.TEXT_NODE) {\n        const text = node.textContent;\n        const idx = searchBackwards ? text.lastIndexOf('\\n') : text.indexOf('\\n');\n        if (idx !== -1) {\n            return { node, offset: searchBackwards ? idx + 1 : idx };\n        }\n    } else {\n        const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);\n        let textNode;\n        while (textNode = walker.nextNode()) {\n            const text = textNode.textContent;\n            const idx = searchBackwards ? text.lastIndexOf('\\n') : text.indexOf('\\n');\n            if (idx !== -1) {\n                return { node: textNode, offset: searchBackwards ? idx + 1 : idx };\n            }\n        }\n    }\n    return null;\n}\n\n/**\n * Helper: Walk through siblings in a given direction to find line boundary\n */\nfunction findLineBoundary(node, container, searchBackwards = false) {\n    let currentNode = node;\n\n    while (currentNode && currentNode !== container) {\n        const sibling = searchBackwards ? currentNode.previousSibling : currentNode.nextSibling;\n        let currentSibling = sibling;\n\n        while (currentSibling) {\n            const result = findTextNodeWithNewline(currentSibling, searchBackwards);\n            if (result) {\n                return result;\n            }\n            currentSibling = searchBackwards ? currentSibling.previousSibling : currentSibling.nextSibling;\n        }\n\n        currentNode = currentNode.parentNode;\n    }\n\n    return null;\n}\n\n/**\n * Helper: Get the last text node in a container\n */\nfunction getLastTextNode(container) {\n    const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);\n    let lastNode = null;\n    let textNode;\n    while (textNode = walker.nextNode()) {\n        lastNode = textNode;\n    }\n    return lastNode;\n}\n\n/**\n * Expands a selection range to include complete lines\n * If a user selects partial text, this ensures full lines are captured\n */\nfunction expandRangeToFullLines(range, container) {\n    const newRange = range.cloneRange();\n\n    // Expand start to line beginning\n    if (newRange.startContainer.nodeType === Node.TEXT_NODE) {\n        const text = newRange.startContainer.textContent;\n        const lastNewline = text.lastIndexOf('\\n', newRange.startOffset - 1);\n        if (lastNewline !== -1) {\n            newRange.setStart(newRange.startContainer, lastNewline + 1);\n        } else {\n            const lineStart = findLineBoundary(newRange.startContainer, container, true);\n            if (lineStart) {\n                newRange.setStart(lineStart.node, lineStart.offset);\n            } else {\n                newRange.setStart(container, 0);\n            }\n        }\n    }\n\n    // Expand end to line end\n    if (newRange.endContainer.nodeType === Node.TEXT_NODE) {\n        const text = newRange.endContainer.textContent;\n        const nextNewline = text.indexOf('\\n', newRange.endOffset);\n        if (nextNewline !== -1) {\n            newRange.setEnd(newRange.endContainer, nextNewline);\n        } else {\n            const lineEnd = findLineBoundary(newRange.endContainer, container, false);\n            if (lineEnd) {\n                newRange.setEnd(lineEnd.node, lineEnd.offset);\n            } else {\n                const lastNode = getLastTextNode(container);\n                newRange.setEnd(\n                    lastNode || container,\n                    lastNode ? lastNode.textContent.length : container.childNodes.length\n                );\n            }\n        }\n    }\n\n    return newRange;\n}\n\n/**\n * Create a temporary element with the selected content styled for capture\n */\nfunction createCaptureElement(selectedFragment, originalElement) {\n    const originalStyles = window.getComputedStyle(originalElement);\n\n    // Create container with watermark background\n    const container = document.createElement(\"div\");\n    container.innerHTML = `\n        <div style=\"\n            position: absolute;\n            left: -9999px;\n            top: 0;\n            padding: 2px;\n            background-color: transparent;\n        \">\n        <div style=\"\n            background-color: #ffffff;\n            width: ${originalElement.offsetWidth}px;\n            border: 1px solid #ccc;\n            border-radius: 4px;\n            overflow: hidden;\n        \">\n            <!-- Watermark background -->\n            <div style=\"\n                position: absolute;\n                top: 0;\n                left: 0;\n                width: 100%;\n                height: 100%;\n                overflow: hidden;\n                pointer-events: none;\n                z-index: 0;\n                background-image: url(&quot;data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='400' height='200' viewBox='0 0 400 200'><g font-family='Arial' font-size='18' font-weight='700' fill='%23e8e8e8' transform='rotate(-45 200 100)'><text x='0' y='40'>changedetection.io   changedetection.io   changedetection.io</text><text x='0' y='100'>changedetection.io   changedetection.io   changedetection.io</text><text x='0' y='160'>changedetection.io   changedetection.io   changedetection.io</text></g></svg>&quot;);\n                background-repeat: repeat;\n                background-size: 400px 200px;\n            \"></div>\n\n            <!-- Content -->\n            <pre id=\"temp-capture-element\" style=\"\n                position: relative;\n                z-index: 1;\n                white-space: ${originalStyles.whiteSpace};\n                font-family: ${originalStyles.fontFamily};\n                font-size: ${originalStyles.fontSize};\n                line-height: ${originalStyles.lineHeight};\n                color: ${originalStyles.color};\n                word-wrap: ${originalStyles.wordWrap};\n                overflow-wrap: ${originalStyles.overflowWrap};\n                background-color: transparent;\n                padding: ${IMAGE_PADDING}px;\n                border: ${originalStyles.border};\n                box-sizing: border-box;\n                margin: 0;\n            \"></pre>\n        </div>\n        </div>\n    `;\n\n    const outerWrapper = container.firstElementChild;\n    const innerWrapper = outerWrapper.querySelector('div');\n    const tempElement = innerWrapper.querySelector('#temp-capture-element');\n    tempElement.appendChild(selectedFragment);\n\n    // Store innerWrapper for footer appending\n    outerWrapper._innerWrapper = innerWrapper;\n\n    return outerWrapper;\n}\n\n/**\n * Count lines in a text string or document fragment\n */\nfunction countLines(content) {\n    if (!content) return 0;\n\n    let text = '';\n    if (typeof content === 'string') {\n        text = content;\n    } else if (content.textContent) {\n        text = content.textContent;\n    }\n\n    // Count newlines + 1 (for the last line)\n    const lines = text.split('\\n').length;\n    return lines > 0 ? lines : 1;\n}\n\n/**\n * Create footer with metadata (URL, version info, line count)\n */\nfunction createFooter(selectedLines, totalLines) {\n    const url = getTargetUrl();\n    const versionInfo = getVersionInfo();\n    const lineInfo = (selectedLines && totalLines) ? ` - ${selectedLines} of ${totalLines} lines selected` : '';\n\n    const footer = document.createElement(\"div\");\n    footer.innerHTML = `\n        <div style=\"\n            position: relative;\n            z-index: 1;\n            background-color: #1324fd;\n            color: #fff;\n            padding: 10px;\n            margin-top: 10px;\n            font-size: 12px;\n            font-family: Arial, sans-serif;\n            line-height: 1.5;\n            border-top: 1px solid #ccc;\n        \">\n            Watched URL: <strong>${url}</strong><br>\n            ${versionInfo}${lineInfo}<br>\n            Monitored via automated content change detection on public webpages. Data reflects observed text updates, not editorial verification.\n        </div>\n    `;\n\n    return footer.firstElementChild;\n}\n\n/**\n * Add EXIF metadata to JPEG image\n */\nfunction addExifMetadata(jpegDataUrl) {\n    if (typeof piexif === 'undefined') {\n        return jpegDataUrl;\n    }\n\n    try {\n        const url = getTargetUrl();\n        const timestamp = new Date().toISOString();\n\n        const exifObj = {\n            \"0th\": {\n                [piexif.ImageIFD.Software]: \"changedetection.io\",\n                [piexif.ImageIFD.ImageDescription]: `Diff snapshot from ${url}`,\n                [piexif.ImageIFD.Copyright]: \"Generated by changedetection.io\"\n            },\n            \"Exif\": {\n                [piexif.ExifIFD.DateTimeOriginal]: timestamp,\n                [piexif.ExifIFD.UserComment]: `URL: ${url} | Captured: ${timestamp} | Source: changedetection.io`\n            }\n        };\n\n        const exifBytes = piexif.dump(exifObj);\n        return piexif.insert(exifBytes, jpegDataUrl);\n    } catch (error) {\n        console.warn(\"Failed to add EXIF metadata:\", error);\n        return jpegDataUrl;\n    }\n}\n\n/**\n * Convert data URL to Blob for sharing\n */\nfunction dataURLtoBlob(dataURL) {\n    const parts = dataURL.split(',');\n    const byteString = atob(parts[1]);\n    const mimeString = parts[0].split(':')[1].split(';')[0];\n    const ab = new ArrayBuffer(byteString.length);\n    const ia = new Uint8Array(ab);\n    for (let i = 0; i < byteString.length; i++) {\n        ia[i] = byteString.charCodeAt(i);\n    }\n    return new Blob([ab], { type: mimeString });\n}\n\n/**\n * Download the image\n */\nfunction downloadImage(jpegDataUrl) {\n    const a = document.createElement(\"a\");\n    a.href = jpegDataUrl;\n    a.download = \"changedetection-diff-\" + Date.now() + \".jpg\";\n    a.click();\n}\n\n/**\n * Copy image to clipboard\n */\nasync function copyImageToClipboard(jpegDataUrl) {\n    try {\n        const blob = dataURLtoBlob(jpegDataUrl);\n        await navigator.clipboard.write([\n            new ClipboardItem({ 'image/jpeg': blob })\n        ]);\n        alert('Image copied to clipboard!');\n    } catch (error) {\n        console.error('Failed to copy image:', error);\n        alert('Failed to copy image. Your browser may not support this feature.');\n    }\n}\n\n/**\n * Share via Web Share API or fallback to platform-specific sharing\n */\nasync function shareImage(platform, jpegDataUrl) {\n    const url = getTargetUrl();\n    const shareText = `Check out this change detected on ${url} via changedetection.io`;\n    const filename = \"changedetection-diff-\" + Date.now() + \".jpg\";\n\n    // Try Web Share API first (works on mobile and some desktop browsers)\n    if (platform === 'native' && navigator.share) {\n        try {\n            const blob = dataURLtoBlob(jpegDataUrl);\n            const file = new File([blob], filename, { type: 'image/jpeg' });\n\n            await navigator.share({\n                title: 'Change Detection Diff',\n                text: shareText,\n                files: [file]\n            });\n            return;\n        } catch (error) {\n            if (error.name !== 'AbortError') {\n                console.error('Web Share API failed:', error);\n            }\n            return;\n        }\n    }\n\n    // Platform-specific fallbacks\n    const encodedText = encodeURIComponent(shareText);\n    const encodedUrl = encodeURIComponent(url);\n\n    let shareUrl;\n    switch (platform) {\n        case 'twitter':\n            shareUrl = `https://twitter.com/intent/tweet?text=${encodedText}`;\n            break;\n        case 'facebook':\n            shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}&quote=${encodedText}`;\n            break;\n        case 'linkedin':\n            shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;\n            break;\n        case 'reddit':\n            shareUrl = `https://reddit.com/submit?url=${encodedUrl}&title=${encodeURIComponent('Change Detection Diff')}`;\n            break;\n        case 'email':\n            shareUrl = `mailto:?subject=${encodeURIComponent('Change Detection Diff')}&body=${encodedText}`;\n            break;\n        default:\n            return;\n    }\n\n    window.open(shareUrl, '_blank', 'width=600,height=400');\n}\n\n/**\n * Display or download the generated image\n */\nfunction displayImage(jpegDataUrl) {\n    const win = window.open();\n    if (win) {\n        win.document.write(`\n            <html>\n                <head>\n                    <title>Diff Screenshot</title>\n                    <style>\n                        body {\n                            margin: 0;\n                            padding: 20px;\n                            font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Arial, sans-serif;\n                            background: #f5f5f5;\n                        }\n                        .container {\n                            max-width: 1200px;\n                            margin: 0 auto;\n                            background: white;\n                            padding: 20px;\n                            border-radius: 8px;\n                            box-shadow: 0 2px 8px rgba(0,0,0,0.1);\n                        }\n                        img {\n                            max-width: 100%;\n                            display: block;\n                            margin-bottom: 20px;\n                            border: 1px solid #ddd;\n                            border-radius: 4px;\n                        }\n                        .share-section {\n                            padding: 20px 0;\n                            border-top: 2px solid #e0e0e0;\n                        }\n                        .share-section h3 {\n                            margin: 0 0 15px 0;\n                            color: #333;\n                            font-size: 18px;\n                        }\n                        .share-buttons {\n                            display: flex;\n                            flex-wrap: wrap;\n                            gap: 10px;\n                        }\n                        .share-btn {\n                            padding: 10px 20px;\n                            border: none;\n                            border-radius: 6px;\n                            font-size: 14px;\n                            font-weight: 600;\n                            cursor: pointer;\n                            transition: all 0.2s;\n                            text-decoration: none;\n                            display: inline-flex;\n                            align-items: center;\n                            gap: 8px;\n                        }\n                        .share-btn:hover {\n                            transform: translateY(-2px);\n                            box-shadow: 0 4px 12px rgba(0,0,0,0.15);\n                        }\n                        .btn-download {\n                            background: #4CAF50;\n                            color: white;\n                        }\n                        .btn-native {\n                            background: #2196F3;\n                            color: white;\n                        }\n                        .btn-twitter {\n                            background: #000000;\n                            color: white;\n                        }\n                        .btn-facebook {\n                            background: #1877F2;\n                            color: white;\n                        }\n                        .btn-linkedin {\n                            background: #0A66C2;\n                            color: white;\n                        }\n                        .btn-reddit {\n                            background: #FF4500;\n                            color: white;\n                        }\n                        .btn-email {\n                            background: #757575;\n                            color: white;\n                        }\n                    </style>\n                </head>\n                <body>\n                    <div class=\"container\">\n                        <img src=\"${jpegDataUrl}\" alt=\"Diff Screenshot\" id=\"diffImage\"/>\n\n                        <div class=\"share-section\">\n                            <h3>Share or Download</h3>\n                            <p style=\"margin: 0 0 15px 0; padding: 12px; background: #f0f7ff; border-left: 4px solid #2196F3; color: #333; font-size: 14px; line-height: 1.5;\">\n                                <strong>💡 Tip:</strong> Right-click the image above and select \"Copy Image\", then click a share button below and paste it into your post (Ctrl+V or right-click → Paste).\n                            </p>\n                            <div class=\"share-buttons\">\n                                <button class=\"share-btn btn-download\" onclick=\"downloadImage()\">\n                                    📥 Download Image\n                                </button>\n                                ${navigator.share ? '<button class=\"share-btn btn-native\" onclick=\"shareNative()\">📤 Share...</button>' : ''}\n                                <button class=\"share-btn btn-twitter\" onclick=\"shareToTwitter()\">\n                                    𝕏 Share to X\n                                </button>\n                                <button class=\"share-btn btn-facebook\" onclick=\"shareToFacebook()\">\n                                    Share to Facebook\n                                </button>\n                                <button class=\"share-btn btn-linkedin\" onclick=\"shareToLinkedIn()\">\n                                    Share to LinkedIn\n                                </button>\n                                <button class=\"share-btn btn-reddit\" onclick=\"shareToReddit()\">\n                                    Share to Reddit\n                                </button>\n                                <button class=\"share-btn btn-email\" onclick=\"shareViaEmail()\">\n                                    📧 Share via Email\n                                </button>\n                            </div>\n                        </div>\n                    </div>\n\n                    <script>\n                        const imageDataUrl = \"${jpegDataUrl}\";\n\n                        function dataURLtoBlob(dataURL) {\n                            const parts = dataURL.split(',');\n                            const byteString = atob(parts[1]);\n                            const mimeString = parts[0].split(':')[1].split(';')[0];\n                            const ab = new ArrayBuffer(byteString.length);\n                            const ia = new Uint8Array(ab);\n                            for (let i = 0; i < byteString.length; i++) {\n                                ia[i] = byteString.charCodeAt(i);\n                            }\n                            return new Blob([ab], { type: mimeString });\n                        }\n\n                        function downloadImage() {\n                            const a = document.createElement(\"a\");\n                            a.href = imageDataUrl;\n                            a.download = \"changedetection-diff-\" + Date.now() + \".jpg\";\n                            a.click();\n                        }\n\n                        async function shareNative() {\n                            try {\n                                const blob = dataURLtoBlob(imageDataUrl);\n                                const file = new File([blob], \"changedetection-diff-\" + Date.now() + \".jpg\", { type: 'image/jpeg' });\n                                await navigator.share({\n                                    title: 'Change Detection Diff',\n                                    text: 'Check out this change detected via changedetection.io',\n                                    files: [file]\n                                });\n                            } catch (error) {\n                                if (error.name !== 'AbortError') {\n                                    console.error('Share failed:', error);\n                                }\n                            }\n                        }\n\n                        function shareToTwitter() {\n                            const text = encodeURIComponent('Check out this change detected via changedetection.io');\n                            window.open('https://twitter.com/intent/tweet?text=' + text, '_blank', 'width=600,height=400');\n                        }\n\n                        function shareToFacebook() {\n                            const cdUrl = encodeURIComponent('https://changedetection.io');\n                            window.open('https://www.facebook.com/sharer/sharer.php?u=' + cdUrl, '_blank', 'width=600,height=400');\n                        }\n\n                        function shareToLinkedIn() {\n                            const cdUrl = encodeURIComponent('https://changedetection.io');\n                            window.open('https://www.linkedin.com/sharing/share-offsite/?url=' + cdUrl, '_blank', 'width=600,height=400');\n                        }\n\n                        function shareToReddit() {\n                            const cdUrl = encodeURIComponent('https://changedetection.io');\n                            const title = encodeURIComponent('Change Detection Tool');\n                            window.open('https://reddit.com/submit?url=' + cdUrl + '&title=' + title, '_blank', 'width=600,height=400');\n                        }\n\n                        function shareViaEmail() {\n                            const subject = encodeURIComponent('Change Detection Diff');\n                            const body = encodeURIComponent('Check out this change detected via changedetection.io');\n                            window.location.href = 'mailto:?subject=' + subject + '&body=' + body;\n                        }\n                    </script>\n                </body>\n            </html>\n        `);\n    } else {\n        // Fallback: trigger download if popup is blocked\n        const a = document.createElement(\"a\");\n        a.href = jpegDataUrl;\n        a.download = \"changedetection-diff-\" + Date.now() + \".jpg\";\n        a.click();\n    }\n}\n\n/**\n * Update button UI state\n */\nfunction setButtonState(button, isLoading, originalHtml = '') {\n    if (!button) return;\n\n    if (isLoading) {\n        button.innerHTML = 'Generating...';\n        button.style.opacity = \"0.5\";\n        button.style.pointerEvents = \"none\";\n    } else {\n        button.innerHTML = originalHtml;\n        button.style.opacity = \"1\";\n        button.style.pointerEvents = \"auto\";\n    }\n}\n\n/**\n * Main function: Convert selected diff text to a shareable JPEG image\n *\n * Features:\n * - Expands partial selections to full lines\n * - Preserves all diff highlighting and formatting\n * - Adds metadata footer with URL and version info\n * - Embeds EXIF metadata in the JPEG\n * - Opens in new window or downloads if popup blocked\n */\nasync function diffToJpeg() {\n    // Validate dependencies\n    if (typeof html2canvas === 'undefined') {\n        alert(\"html2canvas library is not loaded yet. Please wait a moment and try again.\");\n        return;\n    }\n\n    // Validate selection\n    const selection = window.getSelection();\n    if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {\n        alert(\"Please select the text/lines you want to capture first by highlighting with your mouse.\");\n        return;\n    }\n\n    const originalRange = selection.getRangeAt(0);\n    const differenceElement = document.getElementById(\"difference\");\n\n    if (!differenceElement || !differenceElement.contains(originalRange.commonAncestorContainer)) {\n        alert(\"Please select text within the diff content.\");\n        return;\n    }\n\n    // Setup UI state\n    const btn = document.getElementById(\"share-as-image-btn\");\n    const originalBtnHtml = btn ? btn.innerHTML : '';\n    setButtonState(btn, true);\n\n    let tempElement = null;\n\n    try {\n        // Expand selection to full lines and clone content\n        const expandedRange = expandRangeToFullLines(originalRange, differenceElement);\n        const selectedFragment = expandedRange.cloneContents();\n\n        // Count lines for footer\n        const selectedLines = countLines(selectedFragment);\n        const totalLines = countLines(differenceElement);\n\n        // Create temporary element with proper styling\n        tempElement = createCaptureElement(selectedFragment, differenceElement);\n        // Append footer to innerWrapper (inside the border), not outerWrapper\n        tempElement._innerWrapper.appendChild(createFooter(selectedLines, totalLines));\n\n        // Add to DOM for rendering\n        document.body.appendChild(tempElement);\n\n        // Wait for rendering\n        await new Promise(resolve => setTimeout(resolve, RENDER_DELAY_MS));\n\n        // Capture to canvas\n        const canvas = await html2canvas(tempElement, {\n            scale: CANVAS_SCALE,\n            useCORS: true,\n            allowTaint: true,\n            logging: false,\n            backgroundColor: '#ffffff',\n            scrollX: 0,\n            scrollY: 0\n        });\n\n        // Validate canvas\n        if (canvas.width === 0 || canvas.height === 0) {\n            throw new Error(\"Canvas is empty - no content captured\");\n        }\n\n        // Convert to JPEG\n        let jpeg = canvas.toDataURL(\"image/jpeg\", JPEG_QUALITY);\n\n        if (jpeg === \"data:,\" || jpeg.length < 100) {\n            throw new Error(\"Failed to generate image data\");\n        }\n\n        // Add EXIF metadata\n        jpeg = addExifMetadata(jpeg);\n\n        // Display the image\n        displayImage(jpeg);\n\n        // Clear selection\n        selection.removeAllRanges();\n\n    } catch (error) {\n        console.error(\"Error generating image:\", error);\n        alert(\"Failed to generate image: \" + error.message);\n    } finally {\n        // Cleanup\n        if (tempElement && tempElement.parentNode) {\n            tempElement.parentNode.removeChild(tempElement);\n        }\n        setButtonState(btn, false, originalBtnHtml);\n    }\n}\n"
  },
  {
    "path": "changedetectionio/static/js/stepper.js",
    "content": "$(document).ready(function(){\n   checkUserVal();\n   $('#fetch_backend input').on('change', checkUserVal);\n});\n\nvar checkUserVal = function(){\n    if($('#fetch_backend input:checked').val()=='html_requests') {\n      $('#request-override').show();\n      $('#webdriver-stepper').hide();\n    } else {\n      $('#request-override').hide();\n      $('#webdriver-stepper').show();\n    }\n};\n\n$('a.row-options').on('click', function(){\n    var row=$(this.closest('tr'));\n    switch($(this).data(\"action\")) {\n      case 'remove':\n        $(row).remove();\n      break;\n      case 'add':\n        var new_row=$(row).clone(true).insertAfter($(row));\n        $('input', new_new).val(\"\");\n      break;\n      case 'add':\n        var new_row=$(row).clone(true).insertAfter($(row));\n        $('input', new_new).val(\"\");\n      break;\n      case 'resend-step':\n\n      break;\n    }\n});"
  },
  {
    "path": "changedetectionio/static/js/tabs.js",
    "content": "// Rewrite this is a plugin.. is all this JS really 'worth it?'\n\nwindow.addEventListener('hashchange', function () {\n    // Only remove active from tab elements, not menu items\n    var tabs = document.querySelectorAll('.tabs li.active');\n    tabs.forEach(function(tab) {\n        tab.classList.remove('active');\n    });\n    document.body.classList.remove('full-width');\n    set_active_tab();\n}, false);\n\nvar has_errors = document.querySelectorAll(\".messages .error\");\nif (!has_errors.length) {\n    if (document.location.hash == \"\") {\n        location.replace(document.querySelector(\".tabs ul li:first-child a\").hash);\n    } else {\n        set_active_tab();\n    }\n} else {\n    focus_error_tab();\n}\n\nfunction set_active_tab() {\n    document.body.classList.remove('full-width');\n    var tab = document.querySelectorAll(\".tabs a[href='\" + location.hash + \"']\");\n    if (tab.length) {\n        tab[0].parentElement.classList.add(\"active\");\n    }\n}\n\nfunction focus_error_tab() {\n    // time to use jquery or vuejs really,\n    // activate the tab with the error\n    var tabs = document.querySelectorAll('.tabs li a'), i;\n    for (i = 0; i < tabs.length; ++i) {\n        var tab_name = tabs[i].hash.replace('#', '');\n        var pane_errors = document.querySelectorAll('#' + tab_name + ' .error')\n        if (pane_errors.length) {\n            document.location.hash = '#' + tab_name;\n            return true;\n        }\n    }\n    return false;\n}\n\n\n\n"
  },
  {
    "path": "changedetectionio/static/js/toast.js",
    "content": "/**\n * Toast - Modern toast notification system\n * Inspired by Toastify, Notyf, and React Hot Toast\n *\n * Usage:\n *   Toast.success('Operation completed!');\n *   Toast.error('Something went wrong');\n *   Toast.info('Here is some information');\n *   Toast.warning('Warning message');\n *   Toast.show('Custom message', { type: 'success', duration: 3000 });\n *\n * License: MIT\n */\n\n(function(window) {\n  'use strict';\n\n  // Toast configuration\n  const defaultConfig = {\n    duration: 5000,        // Auto-dismiss after 5 seconds (0 = no auto-dismiss)\n    position: 'top-center', // top-right, top-center, top-left, bottom-right, bottom-center, bottom-left\n    closeButton: true,     // Show close button\n    progressBar: true,     // Show progress bar\n    pauseOnHover: true,    // Pause auto-dismiss on hover\n    maxToasts: 5,          // Maximum toasts to show at once\n    offset: '20px',        // Offset from edge\n    zIndex: 10000,         // Z-index for toast container\n  };\n\n  let config = { ...defaultConfig };\n  let toastCount = 0;\n  let container = null;\n\n  /**\n   * Initialize toast system with custom config\n   */\n  function init(userConfig = {}) {\n    config = { ...defaultConfig, ...userConfig };\n    createContainer();\n  }\n\n  /**\n   * Create toast container if it doesn't exist\n   */\n  function createContainer() {\n    if (container) return;\n\n    container = document.createElement('div');\n    container.className = `toast-container toast-${config.position}`;\n    container.style.zIndex = config.zIndex;\n    document.body.appendChild(container);\n  }\n\n  /**\n   * Show a toast notification\n   */\n  function show(message, options = {}) {\n    createContainer();\n\n    const toast = createToastElement(message, options);\n\n    // Limit number of toasts\n    const existingToasts = container.querySelectorAll('.toast');\n    if (existingToasts.length >= config.maxToasts) {\n      removeToast(existingToasts[0]);\n    }\n\n    // Add to container\n    container.appendChild(toast);\n\n    // Trigger animation\n    requestAnimationFrame(() => {\n      toast.classList.add('toast-show');\n    });\n\n    // Auto-dismiss\n    if (options.duration !== 0 && (options.duration || config.duration) > 0) {\n      setupAutoDismiss(toast, options.duration || config.duration);\n    }\n\n    return {\n      dismiss: () => removeToast(toast)\n    };\n  }\n\n  /**\n   * Create toast DOM element\n   */\n  function createToastElement(message, options) {\n    const toast = document.createElement('div');\n    toast.className = `toast toast-${options.type || 'default'}`;\n    toast.setAttribute('role', 'alert');\n    toast.setAttribute('aria-live', 'polite');\n\n    // Icon\n    const icon = createIcon(options.type || 'default');\n    if (icon) {\n      toast.appendChild(icon);\n    }\n\n    // Message\n    const messageEl = document.createElement('div');\n    messageEl.className = 'toast-message';\n    messageEl.textContent = message;\n    toast.appendChild(messageEl);\n\n    // Close button\n    if (options.closeButton !== false && config.closeButton) {\n      const closeBtn = document.createElement('button');\n      closeBtn.className = 'toast-close';\n      closeBtn.innerHTML = '&times;';\n      closeBtn.setAttribute('aria-label', 'Close');\n      closeBtn.onclick = () => removeToast(toast);\n      toast.appendChild(closeBtn);\n    }\n\n    // Progress bar\n    if (options.progressBar !== false && config.progressBar && (options.duration || config.duration) > 0) {\n      const progressBar = document.createElement('div');\n      progressBar.className = 'toast-progress';\n      toast.appendChild(progressBar);\n      toast._progressBar = progressBar;\n    }\n\n    return toast;\n  }\n\n  /**\n   * Create icon based on toast type\n   */\n  function createIcon(type) {\n    const iconEl = document.createElement('div');\n    iconEl.className = 'toast-icon';\n\n    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n    svg.setAttribute('viewBox', '0 0 24 24');\n    svg.setAttribute('fill', 'none');\n    svg.setAttribute('stroke', 'currentColor');\n    svg.setAttribute('stroke-width', '2');\n\n    let path = '';\n    switch (type) {\n      case 'success':\n        path = 'M20 6L9 17l-5-5';\n        break;\n      case 'error':\n        path = 'M18 6L6 18M6 6l12 12';\n        break;\n      case 'warning':\n        path = 'M12 9v4m0 4h.01M12 2a10 10 0 100 20 10 10 0 000-20z';\n        svg.setAttribute('stroke-width', '1.5');\n        break;\n      case 'info':\n        path = 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';\n        svg.setAttribute('stroke-width', '1.5');\n        break;\n      default:\n        return null;\n    }\n\n    const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');\n    pathEl.setAttribute('d', path);\n    pathEl.setAttribute('stroke-linecap', 'round');\n    pathEl.setAttribute('stroke-linejoin', 'round');\n    svg.appendChild(pathEl);\n    iconEl.appendChild(svg);\n\n    return iconEl;\n  }\n\n  /**\n   * Setup auto-dismiss with progress bar\n   */\n  function setupAutoDismiss(toast, duration) {\n    let startTime = Date.now();\n    let remainingTime = duration;\n    let isPaused = false;\n    let animationFrame;\n\n    function updateProgress() {\n      if (isPaused) return;\n\n      const elapsed = Date.now() - startTime;\n      const progress = Math.min(elapsed / duration, 1);\n\n      if (toast._progressBar) {\n        toast._progressBar.style.transform = `scaleX(${1 - progress})`;\n      }\n\n      if (progress >= 1) {\n        removeToast(toast);\n      } else {\n        animationFrame = requestAnimationFrame(updateProgress);\n      }\n    }\n\n    // Pause on hover\n    if (config.pauseOnHover) {\n      toast.addEventListener('mouseenter', () => {\n        isPaused = true;\n        remainingTime = duration - (Date.now() - startTime);\n        cancelAnimationFrame(animationFrame);\n      });\n\n      toast.addEventListener('mouseleave', () => {\n        isPaused = false;\n        startTime = Date.now();\n        duration = remainingTime;\n        animationFrame = requestAnimationFrame(updateProgress);\n      });\n    }\n\n    animationFrame = requestAnimationFrame(updateProgress);\n  }\n\n  /**\n   * Remove toast with animation\n   */\n  function removeToast(toast) {\n    if (!toast || !toast.parentElement) return;\n\n    toast.classList.add('toast-hide');\n\n    // Remove after animation\n    setTimeout(() => {\n      if (toast.parentElement) {\n        toast.parentElement.removeChild(toast);\n      }\n    }, 300);\n  }\n\n  // Convenience methods\n  function success(message, options = {}) {\n    return show(message, { ...options, type: 'success' });\n  }\n\n  function error(message, options = {}) {\n    return show(message, { ...options, type: 'error' });\n  }\n\n  function warning(message, options = {}) {\n    return show(message, { ...options, type: 'warning' });\n  }\n\n  function info(message, options = {}) {\n    return show(message, { ...options, type: 'info' });\n  }\n\n  /**\n   * Clear all toasts\n   */\n  function clear() {\n    if (!container) return;\n    const toasts = container.querySelectorAll('.toast');\n    toasts.forEach(removeToast);\n  }\n\n  // Public API\n  window.Toast = {\n    init,\n    show,\n    success,\n    error,\n    warning,\n    info,\n    clear,\n    version: '1.0.0'\n  };\n\n  // Auto-initialize\n  document.addEventListener('DOMContentLoaded', () => {\n    init();\n  });\n\n})(window);\n"
  },
  {
    "path": "changedetectionio/static/js/toggle-theme.js",
    "content": "/**\n * @file\n * Toggles theme between light and dark mode.\n */\n$(document).ready(function () {\n\n    $(\".toggle-light-mode\").on(\"click\", function () {\n        const isDark = $(\"html\").attr(\"data-darkmode\") === \"true\";\n        $(\"html\").attr(\"data-darkmode\", !isDark);\n        setCookieValue(!isDark);\n    });\n\n    const setCookieValue = (value) => {\n        document.cookie = `css_dark_mode=${value};max-age=31536000;path=/`\n    }\n\n    // Search input box behaviour\n    const toggle_search = document.getElementById(\"toggle-search\");\n    const search_q = document.getElementById(\"search-q\");\n    if(search_q) {\n      window.addEventListener('keydown', function (e) {\n        if (e.altKey == true && e.keyCode == 83) {\n          search_q.classList.toggle('expanded');\n          search_q.focus();\n        }\n      });\n\n      search_q.onkeydown = (e) => {\n        var key = e.keyCode || e.which;\n        if (key === 13) {\n          document.searchForm.submit();\n        }\n      };\n      toggle_search.onclick = () => {\n        // Could be that they want to search something once text is in there\n        if (search_q.value.length) {\n          document.searchForm.submit();\n        } else {\n          // If not..\n          search_q.classList.toggle('expanded');\n          search_q.focus();\n        }\n      };\n    }\n\n    $('#heart-us').click(function () {\n        $(\"#overlay\").toggleClass('visible');\n        heartpath.style.fill = document.getElementById(\"overlay\").classList.contains(\"visible\") ? '#ff0000' : 'var(--color-background)';\n    });\n\n    setInterval(function () {\n        $('body').toggleClass('spinner-active', $.active > 0);\n    }, 2000);\n\n});\n"
  },
  {
    "path": "changedetectionio/static/js/vis.js",
    "content": "$(document).ready(function () {\n\n    // Lazy Hide/Show elements mechanism\n    $('[data-visible-for]').hide();\n    function show_related_elem(e) {\n        var n = $(e).attr('name') + \"=\" + $(e).val();\n        if (n === 'fetch_backend=system') {\n            n = \"fetch_backend=\" + default_system_fetch_backend;\n        }\n        $(`[data-visible-for~=\"${n}\"]`).show();\n    }\n    $(':radio').on('keyup keypress blur change click', function (e) {\n        $(`[data-visible-for]`).hide();\n        $('.advanced-options').hide();\n        show_related_elem(this);\n    });\n\n    $(':radio:checked').each(function (e) {\n       show_related_elem(this);\n    })\n\n\n    // Show advanced\n    $('.show-advanced').click(function (e) {\n        $(this).closest('.tab-pane-inner').find('.advanced-options').each(function (e) {\n            $(this).toggle();\n        })\n    });\n});"
  },
  {
    "path": "changedetectionio/static/js/visual-selector.js",
    "content": "// Copyright (C) 2021 Leigh Morresi (dgtlmoon@gmail.com)\n// All rights reserved.\n// yes - this is really a hack, if you are a front-ender and want to help, please get in touch!\n\nlet runInClearMode = false;\n\n$(document).ready(() => {\n    let currentSelections = [];\n    let currentSelection = null;\n    let appendToList = false;\n    let c, xctx, ctx;\n    let xScale = 1, yScale = 1;\n    let selectorImage, selectorImageRect, selectorData;\n    let elementHandlers = {}; // Store references to element selection handlers (needed for draw mode toggling)\n\n    // Box drawing mode variables (for image_ssim_diff processor)\n    let drawMode = false;\n    let isDrawing = false;\n    let isDragging = false;\n    let drawStartX, drawStartY;\n    let dragOffsetX, dragOffsetY;\n    let drawnBox = null;\n    let resizeHandle = null;\n    const HANDLE_SIZE = 8;\n    const isImageProcessor = $('input[value=\"image_ssim_diff\"]').is(':checked');\n\n\n    // Global jQuery selectors with \"Elem\" appended\n    const $selectorCanvasElem = $('#selector-canvas');\n    const $includeFiltersElem = $(\"#include_filters\");\n    const $selectorBackgroundElem = $(\"img#selector-background\");\n    const $selectorCurrentXpathElem = $(\"#selector-current-xpath span\");\n    const $fetchingUpdateNoticeElem = $('.fetching-update-notice');\n    const $selectorWrapperElem = $(\"#selector-wrapper\");\n\n    // Color constants\n    const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)';\n    const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)';\n    const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)';\n    const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)';\n    const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)';\n\n    $('#visualselector-tab').click(() => {\n        $selectorBackgroundElem.off('load');\n        currentSelections = [];\n        bootstrapVisualSelector();\n    });\n\n    function clearReset() {\n        ctx.clearRect(0, 0, c.width, c.height);\n\n        if ($includeFiltersElem.val().length) {\n            alert(\"Existing filters under the 'Filters & Triggers' tab were cleared.\");\n        }\n        $includeFiltersElem.val('');\n\n        currentSelections = [];\n\n        // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector)\n        runInClearMode = true;\n\n        highlightCurrentSelected();\n    }\n\n    function splitToList(v) {\n        return v.split('\\n').map(line => line.trim()).filter(line => line.length > 0);\n    }\n\n    function sortScrapedElementsBySize() {\n        // Sort the currentSelections array by area (width * height) in descending order\n        selectorData['size_pos'].sort((a, b) => {\n            const areaA = a.width * a.height;\n            const areaB = b.width * b.height;\n            return areaB - areaA;\n        });\n    }\n\n    $(document).on('keydown keyup', (event) => {\n        if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') {\n            appendToList = event.type === 'keydown';\n        }\n\n        if (event.type === 'keydown') {\n            if ($selectorBackgroundElem.is(\":visible\") && event.key === \"Escape\") {\n                clearReset();\n            }\n        }\n    });\n\n    $('#clear-selector').on('click', () => {\n        clearReset();\n    });\n    // So if they start switching between visualSelector and manual filters, stop it from rendering old filters\n    $('li.tab a').on('click', () => {\n        runInClearMode = true;\n    });\n\n    if (!window.location.hash || window.location.hash !== '#visualselector') {\n        $selectorBackgroundElem.attr('src', '');\n        return;\n    }\n\n    bootstrapVisualSelector();\n\n    function bootstrapVisualSelector() {\n        $selectorBackgroundElem\n            .on(\"error\", () => {\n                $fetchingUpdateNoticeElem.html(\"<strong>Ooops!</strong> The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.\")\n                    .css('color', '#bb0000');\n                $('#selector-current-xpath, #clear-selector').hide();\n            })\n            .on('load', () => {\n                console.log(\"Loaded background...\");\n                c = document.getElementById(\"selector-canvas\");\n                xctx = c.getContext(\"2d\");\n                ctx = c.getContext(\"2d\");\n                fetchData();\n                $selectorCanvasElem.off(\"mousemove mousedown\");\n            })\n            .attr(\"src\", screenshot_url);\n\n        let s = `${$selectorBackgroundElem.attr('src')}?${new Date().getTime()}`;\n        $selectorBackgroundElem.attr('src', s);\n    }\n\n    function alertIfFilterNotFound() {\n        let existingFilters = splitToList($includeFiltersElem.val());\n        let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath);\n\n        for (let filter of existingFilters) {\n            if (!sizePosXpaths.includes(filter)) {\n                alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`);\n                break;\n            }\n        }\n    }\n\n    function fetchData() {\n        $fetchingUpdateNoticeElem.html(\"Fetching element data..\");\n\n        $.ajax({\n            url: watch_visual_selector_data_url,\n            context: document.body\n        }).done((data) => {\n            $fetchingUpdateNoticeElem.html(\"Rendering..\");\n            selectorData = data;\n\n            sortScrapedElementsBySize();\n            console.log(`Reported browser width from backend: ${data['browser_width']}`);\n\n            // Little sanity check for the user, alert them if something missing\n            alertIfFilterNotFound();\n\n            setScale();\n            reflowSelector();\n\n            // Initialize draw mode after everything is set up\n            initializeDrawMode();\n\n            $fetchingUpdateNoticeElem.fadeOut();\n        });\n    }\n\n    function updateFiltersText() {\n        // Assuming currentSelections is already defined and contains the selections\n        let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath)));\n\n        if (currentSelections.length > 0) {\n            // Convert the Set back to an array and join with newline characters\n            let textboxFilterText = Array.from(uniqueSelections).join(\"\\n\");\n            $includeFiltersElem.val(textboxFilterText);\n        }\n    }\n\n    function setScale() {\n        $selectorWrapperElem.show();\n        selectorImage = $selectorBackgroundElem[0];\n        selectorImageRect = selectorImage.getBoundingClientRect();\n\n        $selectorCanvasElem.attr({\n            'height': selectorImageRect.height,\n            'width': selectorImageRect.width\n        });\n        $selectorWrapperElem.attr('width', selectorImageRect.width);\n        $('#visual-selector-heading').css('max-width', selectorImageRect.width + \"px\")\n\n        xScale = selectorImageRect.width / selectorImage.naturalWidth;\n        yScale = selectorImageRect.height / selectorImage.naturalHeight;\n\n        ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT;\n        ctx.fillStyle = FILL_STYLE_REDLINE;\n        ctx.lineWidth = 3;\n        console.log(\"Scaling set  x: \" + xScale + \" by y:\" + yScale);\n        $(\"#selector-current-xpath\").css('max-width', selectorImageRect.width);\n    }\n\n    function reflowSelector() {\n        $(window).resize(() => {\n            setScale();\n            highlightCurrentSelected();\n        });\n\n        setScale();\n\n        console.log(selectorData['size_pos'].length + \" selectors found\");\n\n        let existingFilters = splitToList($includeFiltersElem.val());\n\n        selectorData['size_pos'].forEach(sel => {\n            if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {\n                console.log(\"highlighting \" + c);\n                currentSelections.push(sel);\n            }\n        });\n\n\n        highlightCurrentSelected();\n        updateFiltersText();\n\n        // Store handler references for later use\n        elementHandlers.handleMouseMove = handleMouseMove.debounce(5);\n        elementHandlers.handleMouseDown = handleMouseDown.debounce(5);\n        elementHandlers.handleMouseLeave = highlightCurrentSelected.debounce(5);\n\n        $selectorCanvasElem.bind('mousemove', elementHandlers.handleMouseMove);\n        $selectorCanvasElem.bind('mousedown', elementHandlers.handleMouseDown);\n        $selectorCanvasElem.bind('mouseleave', elementHandlers.handleMouseLeave);\n\n        function handleMouseMove(e) {\n            if (!e.offsetX && !e.offsetY) {\n                const targetOffset = $(e.target).offset();\n                e.offsetX = e.pageX - targetOffset.left;\n                e.offsetY = e.pageY - targetOffset.top;\n            }\n\n            ctx.fillStyle = FILL_STYLE_HIGHLIGHT;\n\n            selectorData['size_pos'].forEach(sel => {\n                if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale &&\n                    e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) {\n                    setCurrentSelectedText(sel.xpath);\n                    drawHighlight(sel);\n                    currentSelections.push(sel);\n                    currentSelection = sel;\n                    highlightCurrentSelected();\n                    currentSelections.pop();\n                }\n            })\n        }\n\n\n        function setCurrentSelectedText(s) {\n            $selectorCurrentXpathElem[0].innerHTML = s;\n        }\n\n        function drawHighlight(sel) {\n            ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);\n            ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);\n        }\n\n        function handleMouseDown() {\n            // If we are in 'appendToList' mode, grow the list, if not, just 1\n            currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection];\n            highlightCurrentSelected();\n            updateFiltersText();\n        }\n\n    }\n\n    function highlightCurrentSelected() {\n        xctx.fillStyle = FILL_STYLE_GREYED_OUT;\n        xctx.strokeStyle = STROKE_STYLE_REDLINE;\n        xctx.lineWidth = 3;\n        xctx.clearRect(0, 0, c.width, c.height);\n\n        currentSelections.forEach(sel => {\n            //xctx.clearRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);\n            xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);\n        });\n    }\n\n    // ============= BOX DRAWING MODE (for image_ssim_diff processor) =============\n\n    function initializeDrawMode() {\n        if (!isImageProcessor || !c) return;\n\n        const $selectorModeRadios = $('input[name=\"selector-mode\"]');\n        const $boundingBoxField = $('#bounding_box');\n        const $selectionModeField = $('#selection_mode');\n\n        // Load existing selection mode if present\n        const savedMode = $selectionModeField.val();\n        if (savedMode && (savedMode === 'element' || savedMode === 'draw')) {\n            $selectorModeRadios.filter(`[value=\"${savedMode}\"]`).prop('checked', true);\n            console.log('Loaded saved mode:', savedMode);\n        }\n\n        // Load existing bounding box if present\n        const existingBox = $boundingBoxField.val();\n        if (existingBox) {\n            try {\n                const parts = existingBox.split(',').map(p => parseFloat(p));\n                if (parts.length === 4) {\n                    drawnBox = {\n                        x: parts[0] * xScale,\n                        y: parts[1] * yScale,\n                        width: parts[2] * xScale,\n                        height: parts[3] * yScale\n                    };\n                    console.log('Loaded saved bounding box:', existingBox);\n                }\n            } catch (e) {\n                console.error('Failed to parse existing bounding box:', e);\n            }\n        }\n\n        // Update mode when radio changes\n        $selectorModeRadios.off('change').on('change', function() {\n            const newMode = $(this).val();\n            drawMode = newMode === 'draw';\n            console.log('Mode changed to:', newMode);\n\n            // Save the mode to the hidden field\n            $selectionModeField.val(newMode);\n\n            if (drawMode) {\n                enableDrawMode();\n            } else {\n                disableDrawMode();\n            }\n        });\n\n        // Set initial mode based on which radio is checked\n        drawMode = $selectorModeRadios.filter(':checked').val() === 'draw';\n        console.log('Initial mode:', drawMode ? 'draw' : 'element');\n\n        // Save initial mode\n        $selectionModeField.val(drawMode ? 'draw' : 'element');\n\n        if (drawMode) {\n            enableDrawMode();\n        }\n    }\n\n    function enableDrawMode() {\n        console.log('Enabling draw mode...');\n\n        // Unbind element selection handlers\n        $selectorCanvasElem.unbind('mousemove mousedown mouseleave');\n\n        // Set cursor to crosshair\n        $selectorCanvasElem.css('cursor', 'crosshair');\n\n        // Bind draw mode handlers\n        $selectorCanvasElem.on('mousedown', handleDrawMouseDown);\n        $selectorCanvasElem.on('mousemove', handleDrawMouseMove);\n        $selectorCanvasElem.on('mouseup', handleDrawMouseUp);\n        $selectorCanvasElem.on('mouseleave', handleDrawMouseUp);\n\n        // Clear element selections and xpath display\n        currentSelections = [];\n        $includeFiltersElem.val('');\n        $selectorCurrentXpathElem.html('Draw mode - click and drag to select an area');\n\n        // Clear the canvas\n        if (ctx && xctx) {\n            ctx.clearRect(0, 0, c.width, c.height);\n            xctx.clearRect(0, 0, c.width, c.height);\n        }\n\n        // Redraw if we have an existing box\n        if (drawnBox) {\n            drawBox();\n        }\n    }\n\n    function disableDrawMode() {\n        console.log('Disabling draw mode, switching to element mode...');\n\n        // Unbind draw handlers\n        $selectorCanvasElem.unbind('mousedown mousemove mouseup mouseleave');\n\n        // Reset cursor\n        $selectorCanvasElem.css('cursor', 'default');\n\n        // Clear drawn box\n        drawnBox = null;\n        $('#bounding_box').val('');\n\n        // Clear the canvases\n        if (ctx && xctx) {\n            ctx.clearRect(0, 0, c.width, c.height);\n            xctx.clearRect(0, 0, c.width, c.height);\n        }\n\n        // Restore element selections from include_filters\n        currentSelections = [];\n        if (selectorData && selectorData['size_pos']) {\n            let existingFilters = splitToList($includeFiltersElem.val());\n\n            selectorData['size_pos'].forEach(sel => {\n                if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {\n                    console.log(\"Restoring selection: \" + sel.xpath);\n                    currentSelections.push(sel);\n                }\n            });\n        }\n\n        // Re-enable element selection handlers using stored references\n        if (elementHandlers.handleMouseMove) {\n            $selectorCanvasElem.bind('mousemove', elementHandlers.handleMouseMove);\n            $selectorCanvasElem.bind('mousedown', elementHandlers.handleMouseDown);\n            $selectorCanvasElem.bind('mouseleave', elementHandlers.handleMouseLeave);\n        }\n\n        // Restore the element selection display\n        $selectorCurrentXpathElem.html('Hover over elements to select');\n\n        // Highlight the restored selections\n        highlightCurrentSelected();\n    }\n\n    function handleDrawMouseDown(e) {\n        const rect = c.getBoundingClientRect();\n        const x = e.clientX - rect.left;\n        const y = e.clientY - rect.top;\n\n        // Check if clicking on a resize handle\n        if (drawnBox) {\n            resizeHandle = getResizeHandle(x, y);\n            if (resizeHandle) {\n                isDrawing = true;\n                drawStartX = x;\n                drawStartY = y;\n                return;\n            }\n\n            // Check if clicking inside the box (for dragging)\n            if (isInsideBox(x, y)) {\n                isDragging = true;\n                dragOffsetX = x - drawnBox.x;\n                dragOffsetY = y - drawnBox.y;\n                $selectorCanvasElem.css('cursor', 'move');\n                return;\n            }\n        }\n\n        // Start new box\n        isDrawing = true;\n        drawStartX = x;\n        drawStartY = y;\n        drawnBox = { x: x, y: y, width: 0, height: 0 };\n    }\n\n    function handleDrawMouseMove(e) {\n        const rect = c.getBoundingClientRect();\n        const x = e.clientX - rect.left;\n        const y = e.clientY - rect.top;\n\n        // Update cursor based on position\n        if (!isDrawing && !isDragging && drawnBox) {\n            const handle = getResizeHandle(x, y);\n            if (handle) {\n                $selectorCanvasElem.css('cursor', getHandleCursor(handle));\n            } else if (isInsideBox(x, y)) {\n                $selectorCanvasElem.css('cursor', 'move');\n            } else {\n                $selectorCanvasElem.css('cursor', 'crosshair');\n            }\n        }\n\n        // Handle dragging the box\n        if (isDragging) {\n            drawnBox.x = x - dragOffsetX;\n            drawnBox.y = y - dragOffsetY;\n            drawBox();\n            return;\n        }\n\n        if (!isDrawing) return;\n\n        if (resizeHandle) {\n            // Resize existing box\n            resizeBox(x, y);\n        } else {\n            // Draw new box\n            drawnBox.width = x - drawStartX;\n            drawnBox.height = y - drawStartY;\n        }\n\n        drawBox();\n    }\n\n    function handleDrawMouseUp(e) {\n        if (!isDrawing && !isDragging) return;\n\n        isDrawing = false;\n        isDragging = false;\n        resizeHandle = null;\n\n        if (drawnBox) {\n            // Normalize box (handle negative dimensions)\n            if (drawnBox.width < 0) {\n                drawnBox.x += drawnBox.width;\n                drawnBox.width = Math.abs(drawnBox.width);\n            }\n            if (drawnBox.height < 0) {\n                drawnBox.y += drawnBox.height;\n                drawnBox.height = Math.abs(drawnBox.height);\n            }\n\n            // Constrain to canvas bounds\n            drawnBox.x = Math.max(0, Math.min(drawnBox.x, c.width - drawnBox.width));\n            drawnBox.y = Math.max(0, Math.min(drawnBox.y, c.height - drawnBox.height));\n\n            // Save to form field (convert from scaled to natural coordinates)\n            const naturalX = Math.round(drawnBox.x / xScale);\n            const naturalY = Math.round(drawnBox.y / yScale);\n            const naturalWidth = Math.round(drawnBox.width / xScale);\n            const naturalHeight = Math.round(drawnBox.height / yScale);\n\n            $('#bounding_box').val(`${naturalX},${naturalY},${naturalWidth},${naturalHeight}`);\n\n            drawBox();\n        }\n    }\n\n    function drawBox() {\n        if (!drawnBox) return;\n\n        // Clear and redraw\n        ctx.clearRect(0, 0, c.width, c.height);\n        xctx.clearRect(0, 0, c.width, c.height);\n\n        // Draw box\n        ctx.strokeStyle = STROKE_STYLE_REDLINE;\n        ctx.fillStyle = FILL_STYLE_REDLINE;\n        ctx.lineWidth = 3;\n\n        const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;\n        const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;\n        const drawW = Math.abs(drawnBox.width);\n        const drawH = Math.abs(drawnBox.height);\n\n        ctx.strokeRect(drawX, drawY, drawW, drawH);\n        ctx.fillRect(drawX, drawY, drawW, drawH);\n\n        // Draw resize handles\n        if (!isDrawing) {\n            drawResizeHandles(drawX, drawY, drawW, drawH);\n        }\n    }\n\n    function drawResizeHandles(x, y, w, h) {\n        ctx.fillStyle = '#fff';\n        ctx.strokeStyle = '#000';\n        ctx.lineWidth = 1;\n\n        const handles = [\n            { x: x, y: y },                    // top-left\n            { x: x + w, y: y },                // top-right\n            { x: x, y: y + h },                // bottom-left\n            { x: x + w, y: y + h }             // bottom-right\n        ];\n\n        handles.forEach(handle => {\n            ctx.fillRect(handle.x - HANDLE_SIZE/2, handle.y - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE);\n            ctx.strokeRect(handle.x - HANDLE_SIZE/2, handle.y - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE);\n        });\n    }\n\n    function isInsideBox(x, y) {\n        if (!drawnBox) return false;\n\n        const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;\n        const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;\n        const drawW = Math.abs(drawnBox.width);\n        const drawH = Math.abs(drawnBox.height);\n\n        return x >= drawX && x <= drawX + drawW && y >= drawY && y <= drawY + drawH;\n    }\n\n    function getResizeHandle(x, y) {\n        if (!drawnBox) return null;\n\n        const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;\n        const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;\n        const drawW = Math.abs(drawnBox.width);\n        const drawH = Math.abs(drawnBox.height);\n\n        const handles = {\n            'tl': { x: drawX, y: drawY },\n            'tr': { x: drawX + drawW, y: drawY },\n            'bl': { x: drawX, y: drawY + drawH },\n            'br': { x: drawX + drawW, y: drawY + drawH }\n        };\n\n        for (const [key, handle] of Object.entries(handles)) {\n            if (Math.abs(x - handle.x) <= HANDLE_SIZE && Math.abs(y - handle.y) <= HANDLE_SIZE) {\n                return key;\n            }\n        }\n\n        return null;\n    }\n\n    function getHandleCursor(handle) {\n        const cursors = {\n            'tl': 'nw-resize',\n            'tr': 'ne-resize',\n            'bl': 'sw-resize',\n            'br': 'se-resize'\n        };\n        return cursors[handle] || 'crosshair';\n    }\n\n    function resizeBox(x, y) {\n        const dx = x - drawStartX;\n        const dy = y - drawStartY;\n\n        const originalBox = { ...drawnBox };\n\n        switch (resizeHandle) {\n            case 'tl':\n                drawnBox.x = x;\n                drawnBox.y = y;\n                drawnBox.width = originalBox.x + originalBox.width - x;\n                drawnBox.height = originalBox.y + originalBox.height - y;\n                break;\n            case 'tr':\n                drawnBox.y = y;\n                drawnBox.width = x - originalBox.x;\n                drawnBox.height = originalBox.y + originalBox.height - y;\n                break;\n            case 'bl':\n                drawnBox.x = x;\n                drawnBox.width = originalBox.x + originalBox.width - x;\n                drawnBox.height = y - originalBox.y;\n                break;\n            case 'br':\n                drawnBox.width = x - originalBox.x;\n                drawnBox.height = y - originalBox.y;\n                break;\n        }\n\n        drawStartX = x;\n        drawStartY = y;\n    }\n});"
  },
  {
    "path": "changedetectionio/static/js/watch-overview.js",
    "content": "$(function () {\n    function normalizeUrl(el) {\n        const val = el.value.trim();\n        if (val && !/^[a-zA-Z][a-zA-Z\\d+\\-.]*:/.test(val)) {\n            el.value = 'https://' + val;\n        }\n    }\n\n    $('#url').on('blur keydown', function (e) {\n        if (e.type === 'blur' || e.key === 'Enter') {\n            normalizeUrl(this);\n        }\n    });\n\n    $('form').on('submit', function () {\n        normalizeUrl($('#url')[0]);\n    });\n\n    // Remove unviewed status when normally clicked\n    $('.diff-link').click(function () {\n        $(this).closest('.unviewed').removeClass('unviewed');\n    });\n\n    $('td[data-timestamp]').each(function () {\n        $(this).prop('title', new Intl.DateTimeFormat(undefined,\n            {\n                dateStyle: 'full',\n                timeStyle: 'long'\n            }).format($(this).data('timestamp') * 1000));\n    })\n\n    $(\"#checkbox-assign-tag\").click(function (e) {\n        $('#op_extradata').val(prompt(\"Enter a tag name\"));\n    });\n\n\n    $('.history-link').click(function (e) {\n        // Incase they click 'back' in the browser, it should be removed.\n        $(this).closest('tr').removeClass('unviewed');\n    });\n\n    $('.with-share-link > *').click(function () {\n        $(\"#copied-clipboard\").remove();\n\n        var range = document.createRange();\n        var n = $(\"#share-link\")[0];\n        range.selectNode(n);\n        window.getSelection().removeAllRanges();\n        window.getSelection().addRange(range);\n        document.execCommand(\"copy\");\n        window.getSelection().removeAllRanges();\n\n        $('.with-share-link').append('<span style=\"font-size: 80%; color: #fff;\" id=\"copied-clipboard\">Copied to clipboard</span>');\n        $(\"#copied-clipboard\").fadeOut(2500, function () {\n            $(this).remove();\n        });\n    });\n\n    $(\".watch-table tr\").click(function (event) {\n        var tagName = event.target.tagName.toLowerCase();\n        if (tagName === 'tr' || tagName === 'td') {\n            var x = $('input[type=checkbox]', this);\n            if (x) {\n                $(x).click();\n            }\n        }\n    });\n\n    // checkboxes - check all\n    $(\"#check-all\").click(function (e) {\n        $('input[type=checkbox]').not(this).prop('checked', this.checked);\n    });\n\n    const time_check_step_size_seconds=1;\n\n    // checkboxes - show/hide buttons\n    $(\"input[type=checkbox]\").click(function (e) {\n        if ($('input[type=checkbox]:checked').length) {\n            $('#checkbox-operations').slideDown();\n        } else {\n            $('#checkbox-operations').slideUp();\n        }\n    });\n\n    setInterval(function () {\n        // Background ETA completion for 'checking now'\n        $(\".watch-table .checking-now .last-checked\").each(function () {\n            const eta_complete = parseFloat($(this).data('eta_complete'));\n            const fetch_duration = parseInt($(this).data('fetchduration'));\n\n            if (eta_complete + 2 > nowtimeserver && fetch_duration > 3) {\n                const remaining_seconds = Math.abs(eta_complete) - nowtimeserver - 1;\n\n                let r = Math.round((1.0 - (remaining_seconds / fetch_duration)) * 100);\n                if (r < 10) {\n                    r = 10;\n                }\n                if (r >= 90) {\n                    r = 100;\n                }\n                $(this).css('background-size', `${r}% 100%`);\n            } else {\n                // Snap to full complete\n                $(this).css('background-size', `100% 100%`);\n            }\n        });\n\n        nowtimeserver = nowtimeserver + time_check_step_size_seconds;\n    }, time_check_step_size_seconds * 1000);\n});\n\n"
  },
  {
    "path": "changedetectionio/static/js/watch-settings.js",
    "content": "\nfunction request_textpreview_update() {\n    if (!$('body').hasClass('preview-text-enabled')) {\n        console.error(\"Preview text was requested but body tag was not setup\")\n        return\n    }\n\n    const data = {};\n    $('textarea:visible, input:visible').each(function () {\n        const $element = $(this); // Cache the jQuery object for the current element\n        const name = $element.attr('name'); // Get the name attribute of the element\n        data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();\n    });\n\n    $('body').toggleClass('spinner-active', 1);\n\n    $.abortiveSingularAjax({\n        type: \"POST\",\n        url: preview_text_edit_filters_url,\n        data: data,\n        namespace: 'watchEdit'\n    }).done(function (data) {\n        console.debug(data['duration'])\n        $('#filters-and-triggers #text-preview-before-inner').text(data['before_filter']);\n        $('#filters-and-triggers #text-preview-inner')\n            .text(data['after_filter'])\n            .highlightLines([\n                {\n                    'color': 'var(--highlight-trigger-text-bg-color)',\n                    'lines': data['trigger_line_numbers'],\n                    'title': \"Triggers a change if this text appears, AND something changed in the document.\"\n                },\n                {\n                    'color': 'var(--highlight-ignored-text-bg-color)',\n                    'lines': data['ignore_line_numbers'],\n                    'title': \"Ignored for calculating changes, but still shown.\"\n                },\n                {\n                    'color': 'var(--highlight-blocked-text-bg-color)',\n                    'lines': data['blocked_line_numbers'],\n                    'title': \"No change-detection will occur because this text exists.\"\n                }\n            ])\n    }).fail(function (error) {\n        if (error.statusText === 'abort') {\n            console.log('Request was aborted due to a new request being fired.');\n        } else {\n            $('#filters-and-triggers #text-preview-inner').text('There was an error communicating with the server.');\n        }\n    })\n}\n\n\n$(document).ready(function () {\n\n    $('#notification-setting-reset-to-default').click(function (e) {\n        $('#notification_title').val('');\n        $('#notification_body').val('');\n        $('#notification_format').val('System default');\n        $('#notification_urls').val('');\n        $('#notification_muted_none').prop('checked', true); // in the case of a ternary field\n        e.preventDefault();\n    });\n    $(\"#notification-token-toggle\").click(function (e) {\n        e.preventDefault();\n        $('#notification-tokens-info').toggle();\n    });\n\n    toggleOpacity('#time_between_check_use_default', '#time-check-widget-wrapper, #time-between-check-schedule', false);\n\n\n    const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);\n    $(\"#text-preview-inner\").css('max-height', (vh - 300) + \"px\");\n    $(\"#text-preview-before-inner\").css('max-height', (vh - 300) + \"px\");\n\n    $(\"#activate-text-preview\").click(function (e) {\n        $('body').toggleClass('preview-text-enabled')\n        request_textpreview_update();\n        const method = $('body').hasClass('preview-text-enabled') ? 'on' : 'off';\n        $('#filters-and-triggers textarea')[method]('blur', request_textpreview_update.throttle(1000));\n        $('#filters-and-triggers input')[method]('change', request_textpreview_update.throttle(1000));\n        $(\"#filters-and-triggers-tab\")[method]('click', request_textpreview_update.throttle(1000));\n    });\n    $('.minitabs-wrapper').miniTabs({\n        \"Content after filters\": \"#text-preview-inner\",\n        \"Content raw/before filters\": \"#text-preview-before-inner\"\n    });\n});\n\n"
  },
  {
    "path": "changedetectionio/static/styles/.dockerignore",
    "content": "node_modules\npackage-lock.json\n\n"
  },
  {
    "path": "changedetectionio/static/styles/.gitignore",
    "content": "node_modules\npackage-lock.json\n\n"
  },
  {
    "path": "changedetectionio/static/styles/diff-image.css",
    "content": "﻿.comparison-score{padding:1em;background:var(--color-table-stripe);border-radius:4px;margin:1em 0;border:1px solid var(--color-border-table-cell);color:var(--color-text)}.change-detected{color:#d32f2f;font-weight:bold}.no-change{color:#388e3c;font-weight:bold}.comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:1em;margin:1em 1em}@media(max-width: 1200px){.comparison-grid{grid-template-columns:1fr}}.image-comparison{position:relative;width:100%;overflow:hidden;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1);user-select:none}.image-comparison img{display:block;width:100%;height:auto;max-width:100%;border:none;box-shadow:none}.comparison-image-wrapper{position:relative;width:100%;display:flex;align-items:flex-start;justify-content:center;background-color:var(--color-background);background-image:linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0px}.comparison-after{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%)}.comparison-slider{position:absolute;top:0;left:50%;width:4px;height:100%;background:#0078e7;cursor:ew-resize;transform:translateX(-2px);z-index:10}.comparison-handle{position:absolute;top:50%;left:50%;width:48px;height:48px;background:#0078e7;border:3px solid #fff;border-radius:50%;transform:translate(-50%, -50%);box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;cursor:ew-resize;transition:top .1s ease-out}.comparison-handle::after{content:\"⇄\";color:#fff;font-size:24px;font-weight:bold;pointer-events:none}.comparison-labels{position:absolute;top:10px;width:100%;display:flex;justify-content:space-between;padding:0 0px;z-index:5;pointer-events:none}.comparison-label{background:rgba(0,0,0,.7);color:#fff;padding:.5em 1em;border-radius:4px;font-size:.9em;font-weight:bold}.screenshot-panel{text-align:center;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;padding:1em;box-shadow:0 2px 4px rgba(0,0,0,.05)}.screenshot-panel h3{margin:0 0 1em 0;font-size:1.1em;color:var(--color-text);border-bottom:2px solid var(--color-background-button-primary);padding-bottom:.5em}.screenshot-panel.diff h3{border-bottom-color:#d32f2f}.screenshot-panel img{max-width:100%;height:auto;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1)}.version-selector{display:inline-block;margin:0 .5em}.version-selector label{font-weight:bold;margin-right:.5em;color:var(--color-text)}#settings{background:var(--color-background);padding:1.5em;border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05);margin-bottom:2em;border:1px solid var(--color-border-table-cell)}#settings h2{margin-top:0;color:var(--color-text)}.diff-fieldset{border:none;padding:0;margin:0}.edit-link{float:right;margin-top:-0.5em}.comparison-description{color:var(--color-text-input-description);font-size:.9em;margin-bottom:1em}.download-link{color:var(--color-link);text-decoration:none;display:inline-flex;align-items:center;gap:.3em;font-size:.85em}.download-link:hover{text-decoration:underline}.diff-section-header{color:#d32f2f;font-size:.9em;margin-bottom:1em;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:1em}.comparison-history-section{margin-top:3em;padding:1em;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05)}.comparison-history-section h3{color:var(--color-text)}.comparison-history-section p{color:var(--color-text-input-description);font-size:.9em}.history-changed-yes{color:#d32f2f;font-weight:bold}.history-changed-no{color:#388e3c}\n"
  },
  {
    "path": "changedetectionio/static/styles/diff.css",
    "content": "#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces;overflow-wrap:anywhere}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:\"\";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}\n"
  },
  {
    "path": "changedetectionio/static/styles/package.json",
    "content": "{\n  \"name\": \"changedetection.io-theme\",\n  \"version\": \"0.0.3\",\n  \"description\": \"\",\n  \"main\": \"index.js\",\n  \"engines\": {\n    \"node\": \">=18.0.0\"\n  },\n  \"scripts\": {\n    \"watch\": \"sass --watch scss:. --style=compressed --no-source-map\",\n    \"build\": \"sass scss:. --style=compressed --no-source-map\"\n  },\n  \"author\": \"Leigh Morresi / Web Technologies s.r.o.\",\n  \"license\": \"Apache\",\n  \"dependencies\": {\n    \"sass\": \"^1.77.8\"\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/pure-min.css",
    "content": "/*!\nPure v2.0.5\nCopyright 2013 Yahoo!\nLicensed under the BSD License.\nhttps://github.com/pure-css/pure/blob/master/LICENSE\n*/\n/*!\nnormalize.css v | MIT License | git.io/normalize\nCopyright (c) Nicolas Gallagher and Jonathan Neal\n*/\n/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}html{font-family:sans-serif}.hidden,[hidden]{display:none!important}.pure-img{max-width:100%;height:auto;display:block}.pure-g{letter-spacing:-.31em;text-rendering:optimizespeed;font-family:FreeSans,Arimo,\"Droid Sans\",Helvetica,Arial,sans-serif;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-line-pack:start;align-content:flex-start}@media all and (-ms-high-contrast:none),(-ms-high-contrast:active){table .pure-g{display:block}}.opera-only :-o-prefocus,.pure-g{word-spacing:-.43em}.pure-u{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-g [class*=pure-u]{font-family:sans-serif}.pure-u-1,.pure-u-1-1,.pure-u-1-12,.pure-u-1-2,.pure-u-1-24,.pure-u-1-3,.pure-u-1-4,.pure-u-1-5,.pure-u-1-6,.pure-u-1-8,.pure-u-10-24,.pure-u-11-12,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-2-24,.pure-u-2-3,.pure-u-2-5,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24,.pure-u-3-24,.pure-u-3-4,.pure-u-3-5,.pure-u-3-8,.pure-u-4-24,.pure-u-4-5,.pure-u-5-12,.pure-u-5-24,.pure-u-5-5,.pure-u-5-6,.pure-u-5-8,.pure-u-6-24,.pure-u-7-12,.pure-u-7-24,.pure-u-7-8,.pure-u-8-24,.pure-u-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1-24{width:4.1667%}.pure-u-1-12,.pure-u-2-24{width:8.3333%}.pure-u-1-8,.pure-u-3-24{width:12.5%}.pure-u-1-6,.pure-u-4-24{width:16.6667%}.pure-u-1-5{width:20%}.pure-u-5-24{width:20.8333%}.pure-u-1-4,.pure-u-6-24{width:25%}.pure-u-7-24{width:29.1667%}.pure-u-1-3,.pure-u-8-24{width:33.3333%}.pure-u-3-8,.pure-u-9-24{width:37.5%}.pure-u-2-5{width:40%}.pure-u-10-24,.pure-u-5-12{width:41.6667%}.pure-u-11-24{width:45.8333%}.pure-u-1-2,.pure-u-12-24{width:50%}.pure-u-13-24{width:54.1667%}.pure-u-14-24,.pure-u-7-12{width:58.3333%}.pure-u-3-5{width:60%}.pure-u-15-24,.pure-u-5-8{width:62.5%}.pure-u-16-24,.pure-u-2-3{width:66.6667%}.pure-u-17-24{width:70.8333%}.pure-u-18-24,.pure-u-3-4{width:75%}.pure-u-19-24{width:79.1667%}.pure-u-4-5{width:80%}.pure-u-20-24,.pure-u-5-6{width:83.3333%}.pure-u-21-24,.pure-u-7-8{width:87.5%}.pure-u-11-12,.pure-u-22-24{width:91.6667%}.pure-u-23-24{width:95.8333%}.pure-u-1,.pure-u-1-1,.pure-u-24-24,.pure-u-5-5{width:100%}.pure-button{display:inline-block;line-height:normal;white-space:nowrap;vertical-align:middle;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button-group{letter-spacing:-.31em;text-rendering:optimizespeed}.opera-only :-o-prefocus,.pure-button-group{word-spacing:-.43em}.pure-button-group .pure-button{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-button{font-family:inherit;font-size:100%;padding:.5em 1em;color:rgba(0,0,0,.8);border:none transparent;background-color:#e6e6e6;text-decoration:none;border-radius:2px}.pure-button-hover,.pure-button:focus,.pure-button:hover{background-image:-webkit-gradient(linear,left top,left bottom,from(transparent),color-stop(40%,rgba(0,0,0,.05)),to(rgba(0,0,0,.1)));background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{-webkit-box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;border-color:#000}.pure-button-disabled,.pure-button-disabled:active,.pure-button-disabled:focus,.pure-button-disabled:hover,.pure-button[disabled]{border:none;background-image:none;opacity:.4;cursor:not-allowed;-webkit-box-shadow:none;box-shadow:none;pointer-events:none}.pure-button-hidden{display:none}.pure-button-primary,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{background-color:#0078e7;color:#fff}.pure-button-group .pure-button{margin:0;border-radius:0;border-right:1px solid rgba(0,0,0,.2)}.pure-button-group .pure-button:first-child{border-top-left-radius:2px;border-bottom-left-radius:2px}.pure-button-group .pure-button:last-child{border-top-right-radius:2px;border-bottom-right-radius:2px;border-right:none}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{padding:.5em .6em;display:inline-block;border:1px solid #ccc;-webkit-box-shadow:inset 0 1px 3px #ddd;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form input:not([type]){padding:.5em .6em;display:inline-block;border:1px solid #ccc;-webkit-box-shadow:inset 0 1px 3px #ddd;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form input[type=color]{padding:.2em .5em}.pure-form input[type=color]:focus,.pure-form input[type=date]:focus,.pure-form input[type=datetime-local]:focus,.pure-form input[type=datetime]:focus,.pure-form input[type=email]:focus,.pure-form input[type=month]:focus,.pure-form input[type=number]:focus,.pure-form input[type=password]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form input[type=text]:focus,.pure-form input[type=time]:focus,.pure-form input[type=url]:focus,.pure-form input[type=week]:focus,.pure-form select:focus,.pure-form textarea:focus{outline:0;border-color:#129fea}.pure-form input:not([type]):focus{outline:0;border-color:#129fea}.pure-form input[type=checkbox]:focus,.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus{outline:thin solid #129fea;outline:1px auto #129fea}.pure-form .pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=color][disabled],.pure-form input[type=date][disabled],.pure-form input[type=datetime-local][disabled],.pure-form input[type=datetime][disabled],.pure-form input[type=email][disabled],.pure-form input[type=month][disabled],.pure-form input[type=number][disabled],.pure-form input[type=password][disabled],.pure-form input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form input[type=text][disabled],.pure-form input[type=time][disabled],.pure-form input[type=url][disabled],.pure-form input[type=week][disabled],.pure-form select[disabled],.pure-form textarea[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input:not([type])[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input[readonly],.pure-form select[readonly],.pure-form textarea[readonly]{background-color:#eee;color:#777;border-color:#ccc}.pure-form input:focus:invalid,.pure-form select:focus:invalid,.pure-form textarea:focus:invalid{color:#b94a48;border-color:#e9322d}.pure-form input[type=checkbox]:focus:invalid:focus,.pure-form input[type=file]:focus:invalid:focus,.pure-form input[type=radio]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{height:2.25em;border:1px solid #ccc;background-color:#fff}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0 .2em}.pure-form fieldset{margin:0;padding:.35em 0 .75em;border:0}.pure-form legend{display:block;width:100%;padding:.3em 0;margin-bottom:.3em;color:#333;border-bottom:1px solid #e5e5e5}.pure-form-stacked input[type=color],.pure-form-stacked input[type=date],.pure-form-stacked input[type=datetime-local],.pure-form-stacked input[type=datetime],.pure-form-stacked input[type=email],.pure-form-stacked input[type=file],.pure-form-stacked input[type=month],.pure-form-stacked input[type=number],.pure-form-stacked input[type=password],.pure-form-stacked input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=text],.pure-form-stacked input[type=time],.pure-form-stacked input[type=url],.pure-form-stacked input[type=week],.pure-form-stacked label,.pure-form-stacked select,.pure-form-stacked textarea{display:block;margin:.25em 0}.pure-form-stacked input:not([type]){display:block;margin:.25em 0}.pure-form-aligned input,.pure-form-aligned select,.pure-form-aligned textarea,.pure-form-message-inline{display:inline-block;vertical-align:middle}.pure-form-aligned textarea{vertical-align:top}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned .pure-control-group label{text-align:right;display:inline-block;vertical-align:middle;width:10em;margin:0 1em 0 0}.pure-form-aligned .pure-controls{margin:1.5em 0 0 11em}.pure-form .pure-input-rounded,.pure-form input.pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bottom:10px}.pure-form .pure-group input,.pure-form .pure-group textarea{display:block;padding:10px;margin:0 0 -1px;border-radius:0;position:relative;top:-1px}.pure-form .pure-group input:focus,.pure-form .pure-group textarea:focus{z-index:3}.pure-form .pure-group input:first-child,.pure-form .pure-group textarea:first-child{top:1px;border-radius:4px 4px 0 0;margin:0}.pure-form .pure-group input:first-child:last-child,.pure-form .pure-group textarea:first-child:last-child{top:1px;border-radius:4px;margin:0}.pure-form .pure-group input:last-child,.pure-form .pure-group textarea:last-child{top:-2px;border-radius:0 0 4px 4px;margin:0}.pure-form .pure-group button{margin:.35em 0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-3-4{width:75%}.pure-form .pure-input-2-3{width:66%}.pure-form .pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form .pure-input-1-4{width:25%}.pure-form-message-inline{display:inline-block;padding-left:.3em;color:#666;vertical-align:middle;font-size:.875em}.pure-form-message{display:block;color:#666;font-size:.875em}@media only screen and (max-width :480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form input:not([type]),.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form label{margin-bottom:.3em;display:block}.pure-group input:not([type]),.pure-group input[type=color],.pure-group input[type=date],.pure-group input[type=datetime-local],.pure-group input[type=datetime],.pure-group input[type=email],.pure-group input[type=month],.pure-group input[type=number],.pure-group input[type=password],.pure-group input[type=search],.pure-group input[type=tel],.pure-group input[type=text],.pure-group input[type=time],.pure-group input[type=url],.pure-group input[type=week]{margin-bottom:0}.pure-form-aligned .pure-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.pure-form-aligned .pure-controls{margin:1.5em 0 0 0}.pure-form-message,.pure-form-message-inline{display:block;font-size:.75em;padding:.2em 0 .8em}}.pure-menu{-webkit-box-sizing:border-box;box-sizing:border-box}.pure-menu-fixed{position:fixed;left:0;top:0;z-index:3}.pure-menu-item,.pure-menu-list{position:relative}.pure-menu-list{list-style:none;margin:0;padding:0}.pure-menu-item{padding:0;margin:0;height:100%}.pure-menu-heading,.pure-menu-link{display:block;text-decoration:none;white-space:nowrap}.pure-menu-horizontal{width:100%;white-space:nowrap}.pure-menu-horizontal .pure-menu-list{display:inline-block}.pure-menu-horizontal .pure-menu-heading,.pure-menu-horizontal .pure-menu-item,.pure-menu-horizontal .pure-menu-separator{display:inline-block;vertical-align:middle}.pure-menu-item .pure-menu-item{display:block}.pure-menu-children{display:none;position:absolute;left:100%;top:0;margin:0;padding:0;z-index:3}.pure-menu-horizontal .pure-menu-children{left:0;top:auto;width:inherit}.pure-menu-active>.pure-menu-children,.pure-menu-allow-hover:hover>.pure-menu-children{display:block;position:absolute}.pure-menu-has-children>.pure-menu-link:after{padding-left:.5em;content:\"\\25B8\";font-size:small}.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after{content:\"\\25BE\"}.pure-menu-scrollable{overflow-y:scroll;overflow-x:hidden}.pure-menu-scrollable .pure-menu-list{display:block}.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list{display:inline-block}.pure-menu-horizontal.pure-menu-scrollable{white-space:nowrap;overflow-y:hidden;overflow-x:auto;padding:.5em 0}.pure-menu-horizontal .pure-menu-children .pure-menu-separator,.pure-menu-separator{background-color:#ccc;height:1px;margin:.3em 0}.pure-menu-horizontal .pure-menu-separator{width:1px;height:1.3em;margin:0 .3em}.pure-menu-horizontal .pure-menu-children .pure-menu-separator{display:block;width:auto}.pure-menu-heading{text-transform:uppercase;color:#565d64}.pure-menu-link{color:#777}.pure-menu-children{background-color:#fff}.pure-menu-heading,.pure-menu-link{padding:.5em 1em}.pure-menu-disabled{opacity:.5}.pure-menu-disabled .pure-menu-link:hover{background-color:transparent;cursor:default}.pure-menu-active>.pure-menu-link,.pure-menu-link:focus,.pure-menu-link:hover{background-color:#eee}.pure-menu-selected>.pure-menu-link,.pure-menu-selected>.pure-menu-link:visited{color:#000}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table th{border-left:1px solid #cbcbcb;border-width:0 0 0 1px;font-size:inherit;margin:0;overflow:visible;padding:.5em 1em}.pure-table thead{background-color:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child>td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px 0;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child>td{border-bottom-width:0}"
  },
  {
    "path": "changedetectionio/static/styles/scss/_settings.scss",
    "content": "/**\n * SCSS variables (compile-time)\n * These can be used in media queries and other places where CSS custom properties don't work\n */\n\n// Breakpoints\n$desktop-wide-breakpoint: 980px;\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/diff-image.scss",
    "content": "/**\n * Image Comparison Diff Styles\n * Styles for the interactive image comparison slider and screenshot diff visualization\n */\n\n.comparison-score {\n  padding: 1em;\n  background: var(--color-table-stripe);\n  border-radius: 4px;\n  margin: 1em 0;\n  border: 1px solid var(--color-border-table-cell);\n  color: var(--color-text);\n}\n\n.change-detected {\n  color: #d32f2f;\n  font-weight: bold;\n}\n\n.no-change {\n  color: #388e3c;\n  font-weight: bold;\n}\n\n.comparison-grid {\n  display: grid;\n  grid-template-columns: 1fr 1fr;\n  gap: 1em;\n  margin: 1em 1em;\n\n  @media (max-width: 1200px) {\n    grid-template-columns: 1fr;\n  }\n}\n\n/* Interactive Image Comparison Slider */\n.image-comparison {\n  position: relative;\n  width: 100%;\n  overflow: hidden;\n  border: 1px solid var(--color-border-table-cell);\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n  user-select: none;\n\n  img {\n    display: block;\n    width: 100%;\n    height: auto;\n    max-width: 100%;\n    border: none;\n    box-shadow: none;\n  }\n}\n\n/* Image wrappers with checkered background */\n.comparison-image-wrapper {\n  position: relative;\n  width: 100%;\n  display: flex;\n  align-items: flex-start;\n  justify-content: center;\n  /* Very light checkered background pattern */\n  background-color: var(--color-background);\n  background-image:\n    linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),\n    linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),\n    linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),\n    linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);\n  background-size: 20px 20px;\n  background-position: 0 0, 0 10px, 10px -10px, -10px 0px;\n}\n\n.comparison-after {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  clip-path: inset(0 0 0 50%);\n}\n\n.comparison-slider {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  width: 4px;\n  height: 100%;\n  background: #0078e7;\n  cursor: ew-resize;\n  transform: translateX(-2px);\n  z-index: 10;\n}\n\n.comparison-handle {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: 48px;\n  height: 48px;\n  background: #0078e7;\n  border: 3px solid white;\n  border-radius: 50%;\n  transform: translate(-50%, -50%);\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  cursor: ew-resize;\n  transition: top 0.1s ease-out;\n\n  &::after {\n    content: '⇄';\n    color: white;\n    font-size: 24px;\n    font-weight: bold;\n    pointer-events: none;\n  }\n}\n\n.comparison-labels {\n  position: absolute;\n  top: 10px;\n  width: 100%;\n  display: flex;\n  justify-content: space-between;\n  padding: 0 0px;\n  z-index: 5;\n  pointer-events: none;\n}\n\n.comparison-label {\n  background: rgba(0, 0, 0, 0.7);\n  color: white;\n  padding: 0.5em 1em;\n  border-radius: 4px;\n  font-size: 0.9em;\n  font-weight: bold;\n}\n\n.screenshot-panel {\n  text-align: center;\n  background: var(--color-background);\n  border: 1px solid var(--color-border-table-cell);\n  border-radius: 4px;\n  padding: 1em;\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);\n\n  h3 {\n    margin: 0 0 1em 0;\n    font-size: 1.1em;\n    color: var(--color-text);\n    border-bottom: 2px solid var(--color-background-button-primary);\n    padding-bottom: 0.5em;\n  }\n\n  &.diff h3 {\n    border-bottom-color: #d32f2f;\n  }\n\n  img {\n    max-width: 100%;\n    height: auto;\n    border: 1px solid var(--color-border-table-cell);\n    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n  }\n}\n\n.version-selector {\n  display: inline-block;\n  margin: 0 0.5em;\n\n  label {\n    font-weight: bold;\n    margin-right: 0.5em;\n    color: var(--color-text);\n  }\n}\n\n#settings {\n  background: var(--color-background);\n  padding: 1.5em;\n  border-radius: 4px;\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);\n  margin-bottom: 2em;\n  border: 1px solid var(--color-border-table-cell);\n\n  h2 {\n    margin-top: 0;\n    color: var(--color-text);\n  }\n}\n\n.diff-fieldset {\n  border: none;\n  padding: 0;\n  margin: 0;\n}\n\n.edit-link {\n  float: right;\n  margin-top: -0.5em;\n}\n\n.comparison-description {\n  color: var(--color-text-input-description);\n  font-size: 0.9em;\n  margin-bottom: 1em;\n}\n\n.download-link {\n  color: var(--color-link);\n  text-decoration: none;\n  display: inline-flex;\n  align-items: center;\n  gap: 0.3em;\n  font-size: 0.85em;\n\n  &:hover {\n    text-decoration: underline;\n  }\n}\n\n.diff-section-header {\n  color: #d32f2f;\n  font-size: 0.9em;\n  margin-bottom: 1em;\n  font-weight: bold;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 1em;\n}\n\n.comparison-history-section {\n  margin-top: 3em;\n  padding: 1em;\n  background: var(--color-background);\n  border: 1px solid var(--color-border-table-cell);\n  border-radius: 4px;\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);\n\n  h3 {\n    color: var(--color-text);\n  }\n\n  p {\n    color: var(--color-text-input-description);\n    font-size: 0.9em;\n  }\n}\n\n.history-changed-yes {\n  color: #d32f2f;\n  font-weight: bold;\n}\n\n.history-changed-no {\n  color: #388e3c;\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/diff.scss",
    "content": "#diff-form {\n\n  background: rgba(0, 0, 0, .05);\n  padding: 1em;\n  border-radius: 10px;\n  margin-bottom: 1em;\n  color: #fff;\n  font-size: 0.9rem;\n  text-align: center;\n\n  label.from-to-label {\n    width: 4rem;\n    text-decoration: none;\n    padding: 0.5rem;\n\n    &#change-from {\n      color: #b30000;\n      background: #fadad7\n    }\n\n    &#change-to {\n      background: #eaf2c2;\n      color: #406619;\n    }\n  }\n\n  #diff-style {\n    >span {\n      display: inline-block;\n      padding: 0.3em;\n      label {\n        font-weight: normal;\n      }\n    }\n  }\n  * {\n    vertical-align: middle;\n  }\n\n}\n\nbody.difference-page {\n  section.content {\n    padding-top: 40px;\n  }\n}\n\n#diff-ui {\n\n  background: var(--color-background);\n  padding: 1rem;\n  border-radius: 5px;\n\n  @media (min-width: 767px) {\n    min-width: 50%;\n  }\n\n  // The first tab 'text' diff\n  #text {\n    font-size: 11px;\n  }\n\n  pre {\n    white-space: break-spaces;\n    overflow-wrap: anywhere;\n  }\n\n\n  h1 {\n    display: inline;\n    font-size: 100%;\n  }\n\n  #result {\n    white-space: pre-wrap;\n    word-break: break-word;\n    overflow-wrap: break-word;\n  }\n\n\n  .source {\n    position: absolute;\n    right: 1%;\n    top: .2em;\n  }\n\n  @-moz-document url-prefix() {\n    body {\n      height: 99%;\n      /* Hide scroll bar in Firefox */\n    }\n  }\n\n  td#diff-col div {\n    text-align: justify;\n    white-space: pre-wrap;\n  }\n\n  .ignored {\n    background-color: #ccc;\n    /*  border: #0d91fa 1px solid; */\n    opacity: 0.7;\n  }\n\n  .triggered {\n    background-color: #1b98f8;\n  }\n\n  /* ignored and triggered? make it obvious error */\n  .ignored.triggered {\n    background-color: #ff0000;\n  }\n\n  .tab-pane-inner#screenshot {\n    text-align: center;\n\n    img {\n      max-width: 99%;\n    }\n  }\n\n\n  // resets button margin to 0px\n  .pure-form button.reset-margin {\n    margin: 0px;\n  }\n\n  .diff-fieldset {\n    display: flex;\n    align-items: center;\n    gap: 4px;\n    flex-wrap: wrap;\n  }\n\n\n  ul#highlightSnippetActions {\n    list-style-type: none;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 1.5rem;\n    flex-wrap: wrap;\n    padding: 0;\n    margin: 0;\n\n    li {\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      text-align: center;\n      padding: 0.5rem;\n      gap: 0.3rem;\n\n      button, a {\n        white-space: nowrap;\n      }\n    }\n\n    span {\n      font-size: 0.8rem;\n      color: var(--color-text-input-description);\n    }\n  }\n\n  #cell-diff-jump-visualiser {\n    display: flex;\n    flex-direction: row;\n    gap: 1px;\n    background: var(--color-background);\n    border-radius: 3px;\n    overflow-x: hidden;\n    position: sticky;\n    top: 0;\n    z-index: 10;\n    padding-top: 1rem;\n    padding-bottom: 1rem;\n    justify-content: center;\n\n    > div {\n      flex: 1;\n      min-width: 1px;\n      max-width: 10px;\n      height: 10px;\n      background: var(--color-background-button-cancel);\n      opacity: 0.3;\n      border-radius: 1px;\n      transition: opacity 0.2s;\n      position: relative;\n\n      &.deletion {\n        background: #b30000; // Red for deletions\n        opacity: 1;\n      }\n\n      &.insertion {\n        background: #406619; // Green for insertions\n        opacity: 1;\n      }\n\n      &.note {\n        background: #406619; // Orange for changed/notes\n        opacity: 1;\n      }\n\n      &.mixed {\n        background: linear-gradient(to right, #b30000 50%, #406619 50%); // Half red, half green\n        opacity: 1;\n      }\n\n      &.current-position::after {\n        content: '';\n        position: absolute;\n        bottom: -6px;\n        left: 50%;\n        transform: translateX(-50%);\n        width: 0;\n        height: 0;\n        border-left: 4px solid transparent;\n        border-right: 4px solid transparent;\n        border-bottom: 4px solid var(--color-text);\n      }\n\n      &:hover {\n        opacity: 0.8;\n        cursor: pointer;\n      }\n    }\n  }\n}\n\n#text-diff-heading-area {\n  .snapshot-age {\n    padding: 4px;\n    margin: 0.5rem 0;\n    background-color: var(--color-background-snapshot-age);\n    border-radius: 3px;\n    font-weight: bold;\n    margin-bottom: 4px;\n\n    &.error {\n      background-color: var(--color-error-background-snapshot-age);\n      color: var(--color-error-text-snapshot-age);\n    }\n    > * {\n      padding-right: 1rem;\n    }\n  }\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_action_sidebar.scss",
    "content": "// Action Sidebar - Minimal navigation icons with light grey aesthetic\n\n.content-wrapper {\n  display: flex;\n  gap: 0;\n  width: 100%;\n  max-width: 100%;\n  position: relative;\n\n  @media only screen and (max-width: 900px) {\n    flex-direction: column;\n  }\n}\n\n.action-sidebar {\n  position: sticky;\n  top: 100px;\n  flex-shrink: 0;\n  width: 80px;\n  height: fit-content;\n  background: transparent;\n  padding: 1.5rem 0;\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n  align-items: center;\n  z-index: 0;\n\n  @media only screen and (max-width: 900px) {\n    position: relative;\n    top: 0;\n    width: 100%;\n    flex-direction: row;\n    justify-content: space-around;\n    padding: 0;\n    overflow-x: auto;\n  }\n}\n\n.action-sidebar-item {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 0.35rem;\n  padding: 0.75rem 0.5rem;\n  min-width: 64px;\n  text-decoration: none;\n  opacity: 0.8;\n  transition: opacity 0.2s ease;\n\n  &:hover {\n    opacity: 1;\n  }\n\n  &.active {\n    opacity: 1;\n\n    .action-icon {\n      stroke: #fff;\n      stroke-width: 2.5;\n    }\n\n    .action-label {\n      color: #fff;\n      font-weight: 700;\n    }\n  }\n}\n\n.action-icon {\n  width: 28px;\n  height: 28px;\n  stroke: #fff;\n  stroke-width: 2;\n  fill: none;\n  stroke-linecap: round;\n  stroke-linejoin: round;\n  transition: stroke 0.2s ease;\n}\n\n.action-label {\n  font-size: 0.65rem;\n  font-weight: 500;\n  text-align: center;\n  line-height: 1.1;\n  letter-spacing: 0.02em;\n  text-transform: uppercase;\n  color: #fff;\n  transition: color 0.2s ease;\n  max-width: 60px;\n  word-wrap: break-word;\n}\n\n.content-main {\n  flex: 0 1 auto;\n  width: 100%;\n  min-width: 0;\n  padding: 0;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n}\n\n// Dark mode adjustments\nhtml[data-darkmode=true] {\n  .action-icon {\n/*    stroke: #666;*/\n  }\n\n  .action-label {\n/*    color: #666;*/\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_arrows.scss",
    "content": ".arrow {\n  border: solid #1b98f8;\n  border-width: 0 2px 2px 0;\n  display: inline-block;\n  padding: 3px;\n\n  &.right {\n    transform: rotate(-45deg);\n    -webkit-transform: rotate(-45deg);\n  }\n\n  &.left {\n    transform: rotate(135deg);\n    -webkit-transform: rotate(135deg);\n  }\n\n  &.up, &.asc {\n    transform: rotate(-135deg);\n    -webkit-transform: rotate(-135deg);\n  }\n\n  &.down, &.desc {\n    transform: rotate(45deg);\n    -webkit-transform: rotate(45deg);\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_browser-steps.scss",
    "content": "\n#browser_steps {\n  /* convert rows to horizontal cells */\n  th {\n    display: none;\n  }\n\n  li {\n    &.browser-step-with-error {\n      background-color: #ffd6d6;\n      border-radius: 4px;\n    }\n    &:not(:first-child) {\n      &:hover {\n        opacity: 1.0;\n      }\n    }\n    list-style: decimal;\n    padding: 5px;\n    .control {\n      padding-left: 5px;\n      padding-right: 5px;\n      a {\n        font-size: 70%;\n      }\n    }\n    &.empty {\n      padding: 0px;\n      opacity: 0.35;\n      .control {\n        display: none;\n      }\n    }\n    &:hover {\n      background: #eee;\n    }\n    > label {\n      display: none;\n    }\n  }\n}\n\n@media only screen and (min-width: 760px) {\n\n  #browser-steps .flex-wrapper {\n    display: flex;\n    flex-flow: row;\n    height: 70vh;\n    font-size: 80%;\n\n    #browser-steps-ui {\n      flex-grow: 1; /* Allow it to grow and fill the available space */\n      flex-shrink: 1; /* Allow it to shrink if needed */\n      flex-basis: 0; /* Start with 0 base width so it stretches as much as possible */\n      background-color: #eee;\n      border-radius: 5px;\n\n    }\n  }\n\n  #browser-steps-fieldlist {\n    flex-grow: 0;      /* Don't allow it to grow */\n    flex-shrink: 0;    /* Don't allow it to shrink */\n    flex-basis: auto;  /* Base width is determined by the content */\n    max-width: 400px;  /* Set a max width to prevent overflow */\n    padding-left: 1rem;\n    overflow-y: scroll;\n  }\n\n  /*  this is duplicate :( */\n  #browsersteps-selector-wrapper {\n    height: 100% !important;\n  }\n}\n\n/*  this is duplicate :( */\n#browsersteps-selector-wrapper {\n\n  width: 100%;\n  overflow-y: scroll;\n  position: relative;\n  height: 80vh;\n\n  > img {\n    position: absolute;\n    max-width: 100%;\n  }\n\n  > canvas {\n    position: relative;\n    max-width: 100%;\n\n    &:hover {\n      cursor: pointer;\n    }\n  }\n\n  .loader {\n    position: absolute;\n    left: 50%;\n    top: 50%;\n    transform: translate(-50%, -50%);\n    z-index: 100;\n    max-width: 350px;\n    text-align: center;\n  }\n\n  /* nice tall skinny one */\n  .spinner, .spinner:after {\n    width: 80px;\n    height: 80px;\n    font-size: 3px;\n  }\n\n  #browsersteps-click-start {\n    &:hover {\n      cursor: pointer;\n    }\n    color: var(--color-grey-400);\n  }\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_conditions_table.scss",
    "content": "/* Styles for the flexbox-based table replacement for conditions */\n.fieldlist_formfields {\n  width: 100%;\n  background-color: var(--color-background, #fff);\n  border-radius: 4px;\n  border: 1px solid var(--color-border-table-cell, #cbcbcb);\n  \n  /* Header row */\n  .fieldlist-header {\n    display: flex;\n    background-color: var(--color-background-table-thead, #e0e0e0);\n    font-weight: bold;\n    border-bottom: 1px solid var(--color-border-table-cell, #cbcbcb);\n  }\n  \n  .fieldlist-header-cell {\n    flex: 1;\n    padding: 0.5em 1em;\n    text-align: left;\n    \n    &:last-child {\n      flex: 0 0 120px; /* Fixed width for actions column */\n    }\n  }\n  \n  /* Body rows */\n  .fieldlist-body {\n    display: flex;\n    flex-direction: column;\n  }\n  \n  .fieldlist-row {\n    display: flex;\n    border-bottom: 1px solid var(--color-border-table-cell, #cbcbcb);\n    \n    &:last-child {\n      border-bottom: none;\n    }\n    \n    &:nth-child(2n-1) {\n      background-color: var(--color-table-stripe, #f2f2f2);\n    }\n    \n    &.error-row {\n      background-color: var(--color-error-input, #ffdddd);\n    }\n  }\n  \n  .fieldlist-cell {\n    flex: 1;\n    padding: 0.5em 1em;\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    \n    /* Make inputs take up full width of their cell */\n    input, select {\n      width: 100%;\n    }\n    \n    &.fieldlist-actions {\n      flex: 0 0 120px; /* Fixed width for actions column */\n      display: flex;\n      flex-direction: row;\n      align-items: center;\n      gap: 4px;\n    }\n  }\n  \n  /* Error styling */\n  ul.errors {\n    margin-top: 0.5em;\n    margin-bottom: 0;\n    padding: 0.5em;\n    background-color: var(--color-error-background-snapshot-age, #ffdddd);\n    border-radius: 4px;\n    list-style-position: inside;\n  }\n  \n  /* Responsive styles */\n  @media only screen and (max-width: 760px) {\n    .fieldlist-header, .fieldlist-row {\n      flex-direction: column;\n    }\n    \n    .fieldlist-header-cell {\n      display: none;\n    }\n    \n    .fieldlist-row {\n      padding: 0.5em 0;\n      border-bottom: 2px solid var(--color-border-table-cell, #cbcbcb);\n    }\n    \n    .fieldlist-cell {\n      padding: 0.25em 0.5em;\n      \n      &.fieldlist-actions {\n        flex: 1;\n        justify-content: flex-start;\n        padding-top: 0.5em;\n      }\n    }\n    \n    /* Add some spacing between fields on mobile */\n    .fieldlist-cell:not(:last-child) {\n      margin-bottom: 0.5em;\n    }\n    \n    /* Label each cell on mobile view */\n    .fieldlist-cell::before {\n      content: attr(data-label);\n      font-weight: bold;\n      margin-bottom: 0.25em;\n    }\n  }\n}\n\n/* Button styling */\n.fieldlist_formfields {\n  .addRuleRow, .removeRuleRow, .verifyRuleRow {\n    cursor: pointer;\n    border: none;\n    padding: 4px 8px;\n    border-radius: 3px;\n    font-weight: bold;\n    background-color: #aaa;\n    color: var(--color-foreground-text, #fff);\n\n    &:hover {\n      background-color: #999;\n    }\n  }\n\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_darkmode.scss",
    "content": "\n.toggle-light-mode {\n  /* default */\n  .icon-dark {\n    display: none;\n  }\n}\n\nhtml[data-darkmode=\"true\"] {\n  .toggle-light-mode {\n    .icon-light {\n      display: none;\n    }\n\n    .icon-dark {\n      display: block;\n    }\n  }\n}\n\n\n\n\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_diff_image.scss",
    "content": "body.processor-image_ssim_diff {\n  #edit-text-filter {\n    .text-filtering {\n      display: none;\n    }\n  }\n  #conditions-tab {\n    display: none;\n  }\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_edit.scss",
    "content": "ul#conditions_match_logic {\n    list-style: none;\n  input, label, li {\n    display: inline-block;\n  }\n  li {\n    padding-right: 1em;\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_extra_browsers.scss",
    "content": "ul#requests-extra_browsers {\n  list-style: none;\n  /* tidy up the table to look more \"inline\" */\n  li {\n    > label {\n      display: none;\n    }\n\n  }\n\n  /* each proxy entry is a `table` */\n  table {\n    tr {\n      display: table-row; // default display for small screens\n      input[type=text] {\n        width: 100%;\n      }\n    }\n  }\n  \n  // apply inline display for larger screens\n  @media only screen and (min-width: 1280px) {\n    table {\n      tr {\n        display: inline;\n        input[type=text] {\n          width: 100%;\n        }\n      }\n    }\n  }\n}\n\n#extra-browsers-setting {\n  border: 1px solid var(--color-grey-800);\n  border-radius: 4px;\n  margin: 1em;\n   padding: 1em;\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_extra_proxies.scss",
    "content": "ul#requests-extra_proxies {\n  list-style: none;\n  /* tidy up the table to look more \"inline\" */\n  li {\n    > label {\n      display: none;\n    }\n\n  }\n\n  /* each proxy entry is a `table` */\n  table {\n    tr {\n      display: table-row; // default display for small screens\n      input[type=text] {\n        width: 100%;\n      }\n    }\n  }\n  \n  // apply inline display for large screens\n  @media only screen and (min-width: 1024px) {\n    table {\n      tr {\n        display: inline;\n      }\n    }\n  }\n}\n\n#request {\n  /* Auto proxy scan/checker */\n  label[for=proxy] {\n    display: inline-block;\n  }\n}\n\nbody.proxy-check-active {\n  #request {\n    // Padding set by flex layout\n    /*\n    .proxy-status {\n      width: 2em;\n    }\n    */\n\n    .proxy-check-details {\n      font-size: 80%;\n      color: #555;\n      display: block;\n      padding-left: 2em;\n      max-width: 500px;\n    }\n\n    .proxy-timing {\n      font-size: 80%;\n      padding-left: 1rem;\n      color: var(--color-link);\n    }\n  }\n}\n\n\n#recommended-proxy {\n  display: grid;\n  gap: 2rem;\n  padding-bottom: 1em;\n  \n  @media  (min-width: 991px) {\n    grid-template-columns: repeat(2, 1fr);\n  }\n\n  > div {\n    border: 1px #aaa solid;\n    border-radius: 4px;\n    padding: 1em;\n  }\n}\n\n#extra-proxies-setting {\n  border: 1px solid var(--color-grey-800);\n  border-radius: 4px;\n    margin: 1em;\n   padding: 1em;\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_hamburger_menu.scss",
    "content": "// Hamburger Menu for Mobile Navigation\n@use \"../settings\" as *;\n\n.hamburger-menu {\n  display: none;\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  padding: 0.5rem;\n  z-index: 10001;\n  position: relative;\n\n  @media only screen and (max-width: $desktop-wide-breakpoint) {\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    align-items: center;\n  }\n}\n\n.hamburger-icon {\n  width: 24px;\n  height: 20px;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n\n  span {\n    display: block;\n    height: 3px;\n    width: 100%;\n    background: var(--color-text);\n    border-radius: 2px;\n    transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);\n    transform-origin: center;\n  }\n}\n\n.hamburger-menu.active {\n  .hamburger-icon span:nth-child(1) {\n    transform: translateY(8.5px) rotate(45deg);\n  }\n\n  .hamburger-icon span:nth-child(2) {\n    opacity: 0;\n    transform: translateX(-10px);\n  }\n\n  .hamburger-icon span:nth-child(3) {\n    transform: translateY(-8.5px) rotate(-45deg);\n  }\n}\n\n// Mobile menu overlay\n.mobile-menu-overlay {\n  display: none;\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: rgba(0, 0, 0, 0.5);\n  z-index: 9999;\n  opacity: 0;\n  transition: opacity 0.3s ease;\n\n  &.active {\n    display: block;\n    opacity: 1;\n  }\n}\n\n// Mobile menu drawer\n.mobile-menu-drawer {\n  position: fixed;\n  top: 0;\n  right: -280px;\n  width: 280px;\n  height: 100%;\n  background: var(--color-background);\n  opacity: 1;\n  box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);\n  z-index: 10000;\n  transition: right 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);\n  overflow-y: auto;\n  padding-top: 60px;\n\n  &.active {\n    right: 0;\n  }\n\n  .mobile-menu-items {\n    list-style: none;\n    padding: 1rem 0;\n    margin: 0;\n\n    li {\n      border-bottom: 1px solid var(--color-border-table-cell);\n\n      >* {\n        display: block;\n        padding: 1rem 1.5rem;\n        color: var(--color-text);\n        text-decoration: none;\n        font-weight: 500;\n        transition: background 0.2s ease;\n\n        &:hover {\n          background: var(--color-background-menu-link-hover);\n        }\n      }\n      &#menu-pause, &#menu-mute {\n        display: none;\n      }\n    }\n  }\n}\n\n// Logo styling\n.logo-cdio {\n  font-weight: bold;\n  font-size: 1.1rem;\n\n  .logo-cd {\n    color: var(--color-grey-500);\n  }\n\n  .logo-io {\n    color: var(--color-text);\n  }\n}\n\n// Always visible items container\n.menu-always-visible {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  margin-left: auto;\n}\n\n// Hide regular menu items on mobile (but not in mobile drawer)\n@media only screen and (max-width: $desktop-wide-breakpoint) {\n  #top-right-menu .menu-collapsible {\n    display: none !important;\n  }\n\n  .pure-menu-horizontal {\n    overflow-x: visible !important;\n  }\n\n  #nav-menu {\n    overflow-x: visible !important;\n  }\n}\n\n// Desktop - hide mobile menu elements\n@media only screen and (min-width: 1025px) {\n  .hamburger-menu,\n  .mobile-menu-drawer,\n  .mobile-menu-overlay {\n    display: none !important;\n  }\n}\n\nhtml[data-darkmode=true] {\n  .mobile-menu-drawer {\n    box-shadow: -2px 0 8px rgba(0, 0, 0, 0.4);\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_language.scss",
    "content": "#language-selector-flag {\n  display: inline-block;\n  width: 1.2em;\n  height: 1.2em;\n  vertical-align: middle;\n  border-radius: 50%;\n  overflow: hidden;\n  opacity: 0.6;\n  &:hover {\n    opacity: 1.0;\n  }\n}\n\n\n// Language Selector Modal Styles\n.language-list {\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n  padding: 0.5rem 0;\n}\n\n.language-option {\n  display: flex;\n  align-items: center;\n  gap: 1rem;\n  padding: 0.25rem;\n  border-radius: 4px;\n  transition: background-color 0.2s ease;\n  text-decoration: none;\n  color: var(--color-text);\n  border: 1px solid transparent;\n\n  &:hover {\n    background-color: var(--color-background-menu-link-hover);\n    border-color: var(--color-border-table-cell);\n  }\n\n  &.active {\n    background-color: var(--color-link);\n    color: var(--color-text-button);\n    font-weight: 600;\n  }\n\n  .flag {\n    font-size: 1.5rem;\n    flex-shrink: 0;\n  }\n\n  .language-name {\n    flex-grow: 1;\n    font-size: 1rem;\n  }\n}\n\n#language-modal {\n  .language-list {\n    .lang-option {\n      display: inline-block;\n      width: 1.5em;\n      height: 1.5em;\n      vertical-align: middle;\n      margin-right: 0.5em;\n      border-radius: 50%;\n      overflow: hidden;\n    }\n  }\n}\n\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_lister_extra.scss",
    "content": ".watch-table {\n  &.favicon-not-enabled {\n    tr {\n      .favicon {\n        display: none;\n      }\n    }\n  }\n\n  tr {\n    /* make the icons and the text inline-ish */\n    td.inline.title-col {\n      .flex-wrapper {\n        display: flex;\n        align-items: center;\n        gap: 4px;\n      }\n    }\n  }\n\n\n  td,\n  th {\n    vertical-align: middle;\n  }\n\n  tr.has-favicon {\n    &.unviewed {\n      img.favicon {\n        opacity: 1.0 !important;\n      }\n    }\n  }\n\n  .status-icons {\n    white-space: nowrap;\n    display: flex;\n    align-items: center; /* Vertical centering */\n    gap: 4px; /* Space between image and text */\n    > * {\n      vertical-align: middle;\n    }\n  }\n}\n\n.title-col {\n  /* Optional, for spacing */\n  padding: 10px;\n}\n\n.title-wrapper {\n  display: flex;\n  align-items: center; /* Vertical centering */\n  gap: 10px; /* Space between image and text */\n}\n\n/* Make sure .title-col-inner doesn't collapse or misalign */\n.title-col-inner {\n  display: inline-block;\n  vertical-align: middle;\n}\n\n/* favicon styling */\n.watch-table {\n  img.favicon {\n    vertical-align: middle;\n    max-width: 25px;\n    max-height: 25px;\n    height: 25px;\n    padding-right: 4px;\n  }\n\n    // Reserved for future use\n  /*  &.thumbnail-type-screenshot {\n      tr.has-favicon {\n        td.inline.title-col {\n          img.thumbnail {\n            background-color: #fff; !* fallback bg for SVGs without bg *!\n            border-radius: 4px; !* subtle rounded corners *!\n            border: 1px solid #ddd; !* light border for contrast *!\n            box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); !* soft shadow *!\n            filter: contrast(1.05) saturate(1.1) drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.2));\n            object-fit: cover; !* crop/fill if needed *!\n            opacity: 0.8;\n            max-width: 30px;\n            max-height: 30px;\n            height: 30px;\n          }\n        }\n      }\n    }*/\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_login_form.scss",
    "content": "// Modern Login Form - Friendly and Welcoming Design\n\n.login-form {\n  min-height: 52vh;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 2rem 1rem;\n\n  .inner {\n    background: var(--color-background);\n    border-radius: 16px;\n    box-shadow:\n      0 10px 40px rgba(0, 0, 0, 0.08),\n      0 2px 8px rgba(0, 0, 0, 0.04);\n    padding: 3rem 2.5rem;\n    width: 100%;\n    max-width: 420px;\n    position: relative;\n    overflow: hidden;\n    transition: transform 0.3s ease, box-shadow 0.3s ease;\n\n    &:hover {\n\n      box-shadow:\n        0 15px 50px rgba(0, 0, 0, 0.12),\n        0 5px 15px rgba(0, 0, 0, 0.06);\n    }\n  }\n\n  form {\n    margin: 0;\n  }\n\n  fieldset {\n    border: none;\n    padding: 0;\n    margin: 0;\n  }\n\n  .pure-control-group {\n    margin-bottom: 1.75rem;\n\n    &:last-of-type {\n      margin-bottom: 0;\n      margin-top: 2rem;\n    }\n  }\n\n  label {\n    display: block;\n    margin-bottom: 0.5rem;\n    font-weight: 600;\n    font-size: 0.9rem;\n    color: var(--color-text);\n    letter-spacing: 0.01em;\n  }\n\n  input[type=\"password\"] {\n    width: 100%;\n    padding: 0.875rem 1rem;\n    border: 2px solid var(--color-grey-800);\n    border-radius: 8px;\n    font-size: 1rem;\n    background: var(--color-background-input);\n    color: var(--color-text-input);\n    transition: all 0.2s ease;\n    box-sizing: border-box;\n\n    &:focus {\n      outline: none;\n      border-color: var(--color-link);\n      box-shadow: 0 0 0 3px rgba(27, 152, 248, 0.1);\n      transform: translateY(-1px);\n    }\n\n    &::placeholder {\n      color: var(--color-text-input-placeholder);\n    }\n  }\n\n  button[type=\"submit\"] {\n    width: 100%;\n    padding: 0.875rem 1.5rem;\n    font-size: 1rem;\n    font-weight: 600;\n    border-radius: 8px;\n    border: none;\n    background: var(--color-background-button-primary);\n    color: var(--color-text-button);\n    cursor: pointer;\n    transition: all 0.2s ease;\n    box-shadow: 0 2px 8px rgba(27, 152, 248, 0.2);\n\n    &:hover {\n      box-shadow: 0 4px 12px rgba(27, 152, 248, 0.3);\n      background: #0066cc;\n    }\n\n    &:active {\n      transform: translateY(0);\n      box-shadow: 0 2px 4px rgba(27, 152, 248, 0.2);\n    }\n  }\n}\n\n// Messages styling for login page\n.content-main > ul.messages {\n  position: fixed;\n  top: 120px;\n  left: 50%;\n  transform: translateX(-50%);\n  list-style: none;\n  padding: 0;\n  margin: 0;\n  z-index: 1000;\n  min-width: 300px;\n  max-width: 500px;\n\n  li {\n    padding: 1rem 1.25rem;\n    border-radius: 8px;\n    font-size: 0.95rem;\n    line-height: 1.5;\n    font-weight: 500;\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n    animation: slideDown 0.3s ease-out;\n    border: 2px solid transparent;\n\n    &.error {\n      background: #fee;\n      border: 2px solid #ef4444;\n      color: #991b1b;\n      font-weight: 600;\n    }\n\n    &.success {\n      background: #f0fdf4;\n      border: 2px solid #10b981;\n      color: #166534;\n    }\n\n    &.info,\n    &.message {\n      background: #eff6ff;\n      border: 2px solid #3b82f6;\n      color: #1e40af;\n    }\n  }\n}\n\n@keyframes slideDown {\n  from {\n    opacity: 0;\n    transform: translateY(-20px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n// Dark mode adjustments\nhtml[data-darkmode=\"true\"] {\n  .login-form {\n    .inner {\n      box-shadow:\n        0 10px 40px rgba(0, 0, 0, 0.4),\n        0 2px 8px rgba(0, 0, 0, 0.2);\n\n      &:hover {\n        box-shadow:\n          0 15px 50px rgba(0, 0, 0, 0.5),\n          0 5px 15px rgba(0, 0, 0, 0.3);\n      }\n    }\n\n    input[type=\"password\"] {\n      border-color: var(--color-grey-400);\n\n      &:focus {\n        border-color: var(--color-link);\n      }\n    }\n  }\n\n  .content-main > ul.messages {\n    li {\n      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);\n\n      &.error {\n        background: #4a1d1d;\n        border-color: #ef4444;\n        color: #fca5a5;\n      }\n\n      &.success {\n        background: #1a3a2a;\n        border-color: #10b981;\n        color: #86efac;\n      }\n\n      &.info,\n      &.message {\n        background: #1e3a5f;\n        border-color: #3b82f6;\n        color: #93c5fd;\n      }\n    }\n  }\n}\n\n// Mobile adjustments\n@media only screen and (max-width: 768px) {\n  .login-form {\n    min-height: auto;\n    padding: 1rem 0.5rem;\n    padding-top: 5rem; // Space for error message\n\n    .inner {\n      padding: 2rem 1.5rem;\n      border-radius: 12px;\n    }\n  }\n\n  .content-main > ul.messages {\n    top: 70px; // Higher up on mobile to avoid overlap\n    left: 10px;\n    right: 10px;\n    transform: none;\n    min-width: auto;\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_love.scss",
    "content": "#overlay {\n\n  opacity: 0.95;\n  position: fixed;\n\n  width: 350px;\n  max-width: 100%;\n  height: 100%;\n  top: 0;\n  right: -350px;\n  background-color: var(--color-table-stripe);\n  z-index: 2;\n\n  transform: translateX(0);\n  transition: transform .5s ease;\n\n\n  &.visible {\n    transform: translateX(-100%);\n\n  }\n\n  .content {\n    font-size: 0.875rem;\n    padding: 1rem;\n    margin-top: 5rem;\n    max-width: 400px;\n    color: var(--color-watch-table-row-text);\n  }\n}\n\n#heartpath {\n  &:hover {\n    fill: #ff0000 !important;\n    transition: all ease 0.3s !important;\n  }\n  transition: all ease 0.3s !important;\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_menu.scss",
    "content": ".pure-menu-link {\n  padding: 0.5rem 1em;\n  line-height: 1.2rem;\n}\n#menu-mute, #menu-pause {\n  padding-left: 0.3rem;\n  padding-right: 0.3rem;\n  img {\n    height: 1.2rem;\n  }\n}\n\n.pure-menu-item {\n  svg {\n    height: 1.2rem;\n  }\n  * {\n    vertical-align: middle;\n  }\n  .github-link {\n    height: 1.8rem;\n    display: block;\n    svg {\n      height: 100%;\n    }\n  }\n  .bi-heart {\n    &:hover {\n      cursor: pointer;\n    }\n  }\n\n  // Active menu item styling\n  &.active {\n    .pure-menu-link {\n      background-color: var(--color-background-menu-link-hover);\n      color: var(--color-text-menu-link-hover);\n    }\n  }\n}\n\n#cdio-logo {\n  padding-left: 0.5em;\n}\n\n#inline-menu-extras-group {\n  >* {\n    display: inline-block;\n  }\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_minitabs.scss",
    "content": ".minitabs-wrapper {\n  width: 100%;\n\n  > div[id] {\n    padding: 20px;\n    border: 1px solid #ccc;\n    border-top: none;\n  }\n\n  .minitabs-content {\n    width: 100%;\n    display: flex;\n    > div {\n      flex: 1 1 auto;\n      min-width: 0;\n      overflow: scroll;\n    }\n  }\n\n  .minitabs {\n    display: flex;\n    border-bottom: 1px solid #ccc;\n  }\n\n  .minitab {\n    flex: 1;\n    text-align: center;\n    padding: 12px 0;\n    text-decoration: none;\n    color: #333;\n    background-color: #f1f1f1;\n    border: 1px solid #ccc;\n    border-bottom: none;\n    cursor: pointer;\n    transition: background-color 0.3s;\n  }\n\n  .minitab:hover {\n    background-color: #ddd;\n  }\n\n  .minitab.active {\n    background-color: #fff;\n    font-weight: bold;\n  }\n\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_modal.scss",
    "content": "/**\n * Modal dialog styles using HTML5 <dialog> element\n * Provides modern, accessible confirmation dialogs\n */\n\n.modal-dialog {\n  border: none;\n  border-radius: 10px;\n  padding: 0;\n  background: var(--color-background);\n  color: var(--color-text);\n  box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);\n  max-width: 500px;\n  width: 90%;\n\n  &::backdrop {\n    background: rgba(0, 0, 0, 0.6);\n    backdrop-filter: blur(3px);\n    animation: fadeIn 0.2s ease-out;\n  }\n\n  &[open] {\n    animation: slideIn 0.25s ease-out;\n  }\n\n  .modal-header {\n    padding: 1.5rem;\n    border-bottom: 1px solid var(--color-border-table-cell);\n    display: flex;\n    align-items: center;\n    gap: 1rem;\n\n    .modal-icon {\n      font-size: 2rem;\n      line-height: 1;\n      flex-shrink: 0;\n\n      &.warning {\n        color: var(--color-warning);\n      }\n\n      &.danger {\n        color: var(--color-background-button-error);\n      }\n\n      &.info {\n        color: var(--color-background-button-primary);\n      }\n    }\n\n    .modal-title {\n      font-size: 1.3rem;\n      font-weight: bold;\n      margin: 0;\n      color: var(--color-text);\n    }\n  }\n\n  .modal-body {\n    padding: 1.5rem;\n    line-height: 1.6;\n\n    p {\n      margin: 0 0 1rem 0;\n\n      &:last-child {\n        margin-bottom: 0;\n      }\n    }\n\n    strong {\n      color: var(--color-text);\n      font-weight: 600;\n    }\n  }\n\n  .modal-footer {\n    padding: 1rem 1.5rem;\n    border-top: 1px solid var(--color-border-table-cell);\n    display: flex;\n    gap: 0.75rem;\n    justify-content: flex-end;\n    background: var(--color-grey-900);\n\n    button {\n      padding: 0.6rem 1.5rem;\n      border: none;\n      border-radius: 4px;\n      cursor: pointer;\n      font-weight: 500;\n      transition: all 0.2s ease;\n      font-size: 0.95rem;\n\n      &:hover {\n        transform: translateY(-1px);\n        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n      }\n\n      &:active {\n        transform: translateY(0);\n      }\n\n      &.modal-btn-cancel {\n        background: var(--color-background-button-cancel);\n        color: var(--color-grey-200);\n\n        &:hover {\n          background: var(--color-grey-700);\n        }\n      }\n\n      &.modal-btn-confirm {\n        background: var(--color-background-button-primary);\n        color: var(--color-white);\n\n        &:hover {\n          opacity: 0.9;\n        }\n      }\n\n      &.modal-btn-danger {\n        background: var(--color-background-button-error);\n        color: var(--color-white);\n\n        &:hover {\n          background: var(--color-dark-red);\n        }\n      }\n\n      &.modal-btn-warning {\n        background: var(--color-background-button-warning);\n        color: var(--color-white);\n\n        &:hover {\n          opacity: 0.9;\n        }\n      }\n    }\n  }\n}\n\n// Dark mode adjustments\nhtml[data-darkmode=\"true\"] {\n  .modal-dialog {\n    box-shadow: 0 5px 30px rgba(0, 0, 0, 0.7);\n\n    .modal-footer {\n      background: var(--color-grey-200);\n    }\n  }\n}\n\n// Animations\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n@keyframes slideIn {\n  from {\n    opacity: 0;\n    transform: translateY(-20px) scale(0.95);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0) scale(1);\n  }\n}\n\n// Mobile responsive\n@media only screen and (max-width: 760px) {\n  .modal-dialog {\n    width: 95%;\n    max-width: none;\n\n    .modal-header {\n      padding: 1rem;\n\n      .modal-title {\n        font-size: 1.1rem;\n      }\n    }\n\n    .modal-body {\n      padding: 1rem;\n      font-size: 0.95rem;\n    }\n\n    .modal-footer {\n      padding: 0.75rem 1rem;\n      flex-wrap: wrap;\n\n      button {\n        flex: 1;\n        min-width: 120px;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_notification_bubble.scss",
    "content": "// Reusable notification bubble for action sidebar icons\n\n.action-sidebar-item {\n  position: relative;\n\n  .notification-bubble {\n    position: absolute;\n    top: 8px;\n    left: 8px;\n    min-width: 18px;\n    height: 18px;\n    background: #ff4444;\n    color: #fff;\n    font-size: 10px;\n    font-weight: 700;\n    line-height: 18px;\n    text-align: center;\n    border-radius: 9px;\n    padding: 0 2px;\n    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n    pointer-events: none;\n    transition: all 0.2s ease;\n    display: none;\n\n    // Red bubble for errors/urgent\n    &.red-bubble {\n      background: #ff4444;\n    }\n\n    // Blue bubble for informational\n    &.blue-bubble {\n      background: #4a9eff;\n      color: #fff;\n    }\n\n    &.visible {\n      display: block;\n    }\n\n    // Pulse animation when value changes\n    &.pulse {\n      animation: bubblePulse 0.4s ease-out;\n    }\n\n    // Large numbers get smaller font\n    &.large-number {\n      font-size: 8px;\n      min-width: 20px;\n      height: 20px;\n      line-height: 20px;\n      border-radius: 10px;\n    }\n  }\n}\n\n@keyframes bubblePulse {\n  0% {\n    transform: scale(1);\n  }\n  50% {\n    transform: scale(1.3);\n  }\n  100% {\n    transform: scale(1);\n  }\n}\n\n// Dark mode adjustments\nhtml[data-darkmode=true] {\n  .notification-bubble {\n    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_pagination.scss",
    "content": ".pagination-page-info {\n  text-transform: capitalize;\n}\n\n.pagination.menu {\n  > * {\n    display: inline-block;\n  }\n\n  li {\n    display: inline-block;\n  }\n\n  a {\n    padding: 0.65rem;\n    margin: 3px;\n    border: none;\n    background: #444;\n    border-radius: 2px;\n    color: var(--color-text-button);\n    &.disabled {\n      display: none;\n    }\n    &.active {\n      font-weight: bold;\n      background: #888;\n    }\n\n    &:hover {\n      background: #999;\n    }\n  }\n\n\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_preview_text_filter.scss",
    "content": "@use \"minitabs\";\n\nbody.preview-text-enabled {\n\n  @media (min-width: 800px) {\n    #filters-and-triggers > div {\n      display: flex; /* Establishes Flexbox layout */\n      gap: 20px; /* Adds space between the columns */\n      position: relative; /* Ensures the sticky positioning is relative to this parent */\n    }\n  }\n\n  /* layout of the page */\n  #edit-text-filter, #text-preview {\n    flex: 1; /* Each column takes an equal amount of available space */\n    align-self: flex-start; /* Aligns the right column to the start, allowing it to maintain its content height */\n  }\n\n  #edit-text-filter {\n    #pro-tips {\n      display: none;\n    }\n  }\n\n  #text-preview {\n    position: sticky;\n    top: 20px;\n    padding-top: 1rem;\n    padding-bottom: 1rem;\n    display: block !important;\n  }\n\n  #activate-text-preview {\n      background-color: var(--color-grey-500);\n  }\n\n  /* actual preview area */\n  .monospace-preview {\n    background: var(--color-background-input);\n    border: 1px solid var(--color-grey-600);\n    padding: 1rem;\n    color: var(--color-text-input);\n    font-family: \"Courier New\", Courier, monospace; /* Sets the font to a monospace type */\n    font-size: 70%;\n    word-break: break-word;\n    white-space: pre-wrap; /* Preserves whitespace and line breaks like <pre> */\n  }\n}\n\n#activate-text-preview {\n  right: 0;\n  position: absolute;\n  z-index: 3;\n  box-shadow: 1px 1px 4px var(--color-shadow-jump);\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_search_modal.scss",
    "content": "// Search Modal Styles\n\n#search-modal {\n  .modal-body {\n    padding: 2rem 1.5rem;\n\n    .pure-control-group {\n      padding-bottom: 0;\n\n      label {\n        display: block;\n        margin-bottom: 0.5rem;\n        font-size: 0.9rem;\n        font-weight: 600;\n        color: var(--color-text);\n      }\n\n      #search-modal-input {\n        width: 100%;\n        max-width: 100%;\n        box-sizing: border-box;\n        padding: 0.6rem 0.8rem;\n        font-size: 1rem;\n        border: 1px solid var(--color-border-input);\n        border-radius: 4px;\n        background-color: var(--color-background-input);\n        color: var(--color-text-input);\n        box-shadow: inset 0 1px 3px var(--color-shadow-input);\n        transition: border-color 0.2s ease, box-shadow 0.2s ease;\n\n        &:focus {\n          outline: none;\n          border-color: var(--color-link);\n          box-shadow: 0 0 0 3px rgba(27, 152, 248, 0.1);\n        }\n\n        &::placeholder {\n          color: var(--color-text-input-placeholder);\n          opacity: 0.7;\n        }\n      }\n    }\n  }\n}\n\n// Dark mode adjustments\nhtml[data-darkmode=true] {\n  #search-modal {\n    #search-modal-input {\n      &:focus {\n        box-shadow: 0 0 0 3px rgba(89, 189, 251, 0.15);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_socket.scss",
    "content": "// Styles for Socket.IO real-time updates\nbody.checking-now {\n  #checking-now-fixed-tab {\n    display: block !important;\n  }\n}\n\n#checking-now-fixed-tab {\n  background: #ccc;\n  border-radius: 5px;\n  bottom: 0;\n  color: var(--color-text);\n  display: none;\n  font-size: 0.8rem;\n  left: 0;\n  padding: 5px;\n  position: fixed;\n}\n\n\n\n\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_spinners.scss",
    "content": "\n/* spinner */\n.spinner,\n.spinner:after {\n  border-radius: 50%;\n  width: 10px;\n  height: 10px;\n}\n.spinner {\n  margin: 0px auto;\n  font-size: 3px;\n  vertical-align: middle;\n  display: inline-block;\n  text-indent: -9999em;\n  border-top: 1.1em solid rgba(38,104,237, 0.2);\n  border-right: 1.1em solid rgba(38,104,237, 0.2);\n  border-bottom: 1.1em solid rgba(38,104,237, 0.2);\n  border-left: 1.1em solid #2668ed;\n  -webkit-transform: translateZ(0);\n  -ms-transform: translateZ(0);\n  transform: translateZ(0);\n  -webkit-animation: load8 1.1s infinite linear;\n  animation: load8 1.1s infinite linear;\n}\n@-webkit-keyframes load8 {\n  0% {\n    -webkit-transform: rotate(0deg);\n    transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(360deg);\n    transform: rotate(360deg);\n  }\n}\n@keyframes load8 {\n  0% {\n    -webkit-transform: rotate(0deg);\n    transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(360deg);\n    transform: rotate(360deg);\n  }\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_tabs.scss",
    "content": "body.wrapped-tabs {\n  .tabs {\n    ul {\n      grid-template-columns: repeat(auto-fill, minmax(var(--tab-width, 180px), 1fr));\n      grid-auto-flow: row;\n      grid-auto-columns: unset;\n      gap: 0;\n      column-gap: 5px;\n    }\n\n    ul li {\n      border-radius: 0;\n    }\n  }\n}\n\n.tabs {\n  ul {\n    margin: 0px;\n    padding: 0px;\n    display: grid;\n    grid-auto-flow: column;\n    grid-auto-columns: max-content;\n    gap: 5px;\n    list-style: none;\n\n    li {\n      white-space: nowrap;\n      color: var(--color-text-tab);\n      border-top-left-radius: 5px;\n      border-top-right-radius: 5px;\n      background-color: var(--color-background-tab);\n\n      &:not(.active) {\n        &:hover {\n          background-color: var(--color-background-tab-hover);\n        }\n      }\n\n      &.active,\n      :target {\n        background-color: var(--color-background);\n\n        a {\n          color: var(--color-text-tab-active);\n          font-weight: bold;\n        }\n      }\n\n      a {\n        display: block;\n        padding: 0.7em;\n        color: var(--color-text-tab);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_toast.scss",
    "content": "// Toast Notification System\n// Modern, animated toast notifications\n\n.toast-container {\n  position: fixed;\n  display: flex;\n  flex-direction: column;\n  gap: 0.75rem;\n  pointer-events: none;\n  z-index: 10000;\n\n  // Positioning\n  &.toast-top-right {\n    top: 20px;\n    right: 20px;\n  }\n\n  &.toast-top-center {\n    top: 100px;\n    left: 50%;\n    transform: translateX(-50%);\n  }\n\n  &.toast-top-left {\n    top: 20px;\n    left: 20px;\n  }\n\n  &.toast-bottom-right {\n    bottom: 20px;\n    right: 20px;\n  }\n\n  &.toast-bottom-center {\n    bottom: 20px;\n    left: 50%;\n    transform: translateX(-50%);\n  }\n\n  &.toast-bottom-left {\n    bottom: 20px;\n    left: 20px;\n  }\n}\n\n.toast {\n  position: relative;\n  display: flex;\n  align-items: center;\n  gap: 0.75rem;\n  min-width: 300px;\n  max-width: 500px;\n  padding: 1rem 1.25rem;\n  background: var(--color-background);\n  border-radius: 8px;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);\n  pointer-events: auto;\n  overflow: hidden;\n  opacity: 0;\n  transform: translateY(-50px);\n  transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);\n  font-family: inherit;\n\n  &.toast-show {\n    opacity: 1;\n    transform: translateY(0);\n  }\n\n  &.toast-hide {\n    opacity: 0;\n    transform: translateY(-50px) scale(0.95);\n  }\n\n  // Toast types\n  &.toast-success {\n    border-left: 4px solid #10b981;\n\n    .toast-icon {\n      color: #10b981;\n    }\n  }\n\n  &.toast-error {\n    border-left: 4px solid #ef4444;\n\n    .toast-icon {\n      color: #ef4444;\n    }\n  }\n\n  &.toast-warning {\n    border-left: 4px solid #f59e0b;\n\n    .toast-icon {\n      color: #f59e0b;\n    }\n  }\n\n  &.toast-info {\n    border-left: 4px solid #3b82f6;\n\n    .toast-icon {\n      color: #3b82f6;\n    }\n  }\n\n  &.toast-default {\n    border-left: 4px solid var(--color-grey-500);\n  }\n}\n\n.toast-icon {\n  flex-shrink: 0;\n  width: 24px;\n  height: 24px;\n\n  svg {\n    width: 100%;\n    height: 100%;\n  }\n}\n\n.toast-message {\n  flex: 1;\n  font-size: 0.875rem;\n  line-height: 1.5;\n  color: var(--color-text);\n  word-break: break-word;\n  font-family: inherit;\n}\n\n.toast-close {\n  flex-shrink: 0;\n  width: 24px;\n  height: 24px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: transparent;\n  border: none;\n  border-radius: 4px;\n  color: var(--color-grey-500);\n  font-size: 1.5rem;\n  line-height: 1;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  padding: 0;\n  margin-left: 0.25rem;\n\n  &:hover {\n    background: var(--color-grey-800);\n    color: var(--color-text);\n  }\n\n  &:active {\n    transform: scale(0.95);\n  }\n}\n\n.toast-progress {\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  height: 3px;\n  background: currentColor;\n  opacity: 0.3;\n  transform-origin: left;\n  transition: transform linear;\n}\n\n// Dark mode adjustments\nhtml[data-darkmode=true] {\n  .toast {\n    background: var(--color-grey-300);\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);\n  }\n\n  .toast-close:hover {\n    background: var(--color-grey-400);\n  }\n}\n\n// Mobile adjustments\n@media only screen and (max-width: 768px) {\n  .toast-container {\n    left: 50% !important;\n    right: auto !important;\n    top: 80px !important;\n    transform: translateX(-50%) !important;\n    align-items: center;\n\n    &.toast-bottom-right,\n    &.toast-bottom-center,\n    &.toast-bottom-left {\n      top: auto !important;\n      bottom: 80px !important;\n    }\n  }\n\n  .toast {\n    min-width: auto;\n    max-width: none;\n    width: 80vw;\n    transform: translateY(-100px);\n\n    &.toast-show {\n      transform: translateY(0);\n    }\n\n    &.toast-hide {\n      transform: translateY(-100px) scale(0.95);\n    }\n  }\n}\n\n// Accessibility\n@media (prefers-reduced-motion: reduce) {\n  .toast {\n    transition: opacity 0.2s ease;\n    transform: none !important;\n\n    &.toast-show {\n      opacity: 1;\n    }\n\n    &.toast-hide {\n      opacity: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_variables.scss",
    "content": "/**\n * CSS custom properties (aka variables).\n */\n\n:root {\n  --color-white: #fff;\n  --color-grey-50: #111;\n  --color-grey-100: #262626;\n  --color-grey-200: #333;\n  --color-grey-300: #444;\n  --color-grey-325: #555;\n  --color-grey-350: #565d64;\n  --color-grey-400: #666;\n  --color-grey-500: #777;\n  --color-grey-600: #999;\n  --color-grey-700: #cbcbcb;\n  --color-grey-750: #ddd;\n  --color-grey-800: #e0e0e0;\n  --color-grey-850: #eee;\n  --color-grey-900: #f2f2f2;\n  --color-black: #000;\n  --color-dark-red: #a00;\n  --color-light-red: #dd0000;\n\n  --color-background-page: var(--color-grey-100);\n  --color-background-gradient-first: #5ad8f7;\n  --color-background-gradient-second: #2f50af;\n  --color-background-gradient-third: #9150bf;\n  --color-background: var(--color-white);\n  --color-text: var(--color-grey-200);\n  --color-link: #1b98f8;\n  --color-menu-accent: #ed5900;\n  --color-background-code: var(--color-grey-850);\n  --color-error: var(--color-dark-red);\n  --color-error-input: #ffebeb;\n  --color-error-list: var(--color-light-red);\n  --color-table-background: var(--color-background);\n  --color-table-stripe: var(--color-grey-900);\n  --color-text-tab: var(--color-white);\n  --color-background-tab: rgba(255, 255, 255, 0.2);\n  --color-background-tab-hover: rgba(255, 255, 255, 0.5);\n  --color-text-tab-active: #222;\n  --color-api-key: #0078e7;\n\n  --color-background-button-primary: #0078e7;\n  --color-background-button-green: #42dd53;\n  --color-background-button-red: #dd4242;\n  --color-background-button-success: rgb(28, 184, 65);\n  --color-background-button-error: rgb(202, 60, 60);\n  --color-text-button-error: var(--color-white);\n  --color-background-button-warning: rgb(202, 60, 60);\n  --color-text-button-warning: var(--color-white);\n  --color-background-button-secondary: rgb(66, 184, 221);\n  --color-background-button-cancel: rgb(200, 200, 200);\n  --color-text-button: var(--color-white);\n  --color-background-button-tag: rgb(99, 99, 99);\n  --color-background-snapshot-age: #dfdfdf;\n  --color-error-text-snapshot-age: var(--color-white);\n  --color-error-background-snapshot-age: #ff0000;\n  --color-background-button-tag-active: #9c9c9c;\n\n  --color-text-messages: var(--color-white);\n  --color-background-messages-message: rgba(255, 255, 255, .2);\n  --color-background-messages-error: rgba(255, 1, 1, .5);\n  --color-background-messages-notice: rgba(255, 255, 255, .5);\n  --color-border-notification: #ccc;\n\n  --color-background-checkbox-operations: rgba(0, 0, 0, 0.05);\n  --color-warning: #ff3300;\n  --color-border-warning: var(--color-warning);\n  --color-text-legend: var(--color-white);\n\n  --color-link-new-version: #e07171;\n  --color-last-checked: #bbb;\n  --color-text-footer: #444;\n  --color-border-watch-table-cell: #eee;\n\n  --color-text-watch-tag-list: rgba(231, 0, 105, 0.4);\n  --color-background-new-watch-form: rgba(0, 0, 0, 0.05);\n  --color-background-new-watch-input: var(--color-white);\n  --color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);\n  --color-text-new-watch-input: var(--color-text);\n\n  --color-border-input: var(--color-grey-500);\n  --color-shadow-input: var(--color-grey-400);\n  --color-background-input: var(--color-white);\n  --color-text-input: var(--color-text);\n  --color-text-input-description: var(--color-grey-500);\n  --color-text-input-placeholder: var(--color-grey-600);\n\n  --color-background-table-thead: var(--color-grey-800);\n  --color-border-table-cell: var(--color-grey-700);\n\n  --color-text-menu-heading: var(--color-grey-350);\n  --color-text-menu-link: var(--color-grey-500);\n  --color-background-menu-link-hover: var(--color-grey-850);\n  --color-text-menu-link-hover: var(--color-grey-300);\n\n  --color-shadow-jump: var(--color-grey-500);\n  --color-icon-github: var(--color-black);\n  --color-icon-github-hover: var(--color-grey-300);\n\n  --color-watch-table-error: var(--color-dark-red);\n  --color-watch-table-row-text: var(--color-grey-100);\n\n  --highlight-trigger-text-bg-color: #1b98f8;\n  --highlight-ignored-text-bg-color: var(--color-grey-700);\n  --highlight-blocked-text-bg-color: rgb(202, 60, 60);\n}\n\nhtml[data-darkmode=\"true\"] {\n  --color-link: #59bdfb;\n  --color-text: var(--color-white);\n\n  --color-background-gradient-first: #3f90a5;\n  --color-background-gradient-second: #1e316c;\n  --color-background-gradient-third: #4d2c64;\n\n  --color-background-new-watch-input: var(--color-grey-100);\n  --color-background-new-watch-input-transparent: var(--color-grey-100);\n  --color-text-new-watch-input: var(--color-text);\n  --color-background-table-thead: var(--color-grey-200);\n  --color-table-background: var(--color-grey-300);\n  --color-table-stripe: var(--color-grey-325);\n  --color-background: var(--color-grey-300);\n  --color-text-menu-heading: var(--color-grey-850);\n  --color-text-menu-link: var(--color-grey-800);\n  --color-border-table-cell: var(--color-grey-400);\n  --color-text-tab-active: var(--color-text);\n\n  --color-border-input: var(--color-grey-400);\n  --color-shadow-input: var(--color-grey-50);\n  --color-background-input: var(--color-grey-350);\n  --color-text-input-description: var(--color-grey-600);\n  --color-text-input-placeholder: var(--color-grey-600);\n  --color-text-watch-tag-list: rgba(250, 62, 146, 0.4);\n  --color-background-code: var(--color-grey-200);\n\n  --color-background-tab: rgba(0, 0, 0, 0.2);\n  --color-background-tab-hover: rgba(0, 0, 0, 0.5);\n\n  --color-background-snapshot-age: var(--color-grey-200);\n  --color-shadow-jump: var(--color-grey-200);\n  --color-icon-github: var(--color-white);\n  --color-icon-github-hover: var(--color-grey-700);\n  --color-watch-table-error: var(--color-light-red);\n  --color-watch-table-row-text: var(--color-grey-800);\n\n\n  .icon-spread {\n    filter: hue-rotate(-10deg) brightness(1.5);\n  }\n\n  .watch-table {\n\n    .title-col a[target=\"_blank\"]::after,\n    .current-diff-url::after {\n      filter: invert(.5) hue-rotate(10deg) brightness(2);\n    }\n\n    .status-browsersteps {\n      filter: invert(.5) hue-rotate(10deg) brightness(1.5);\n    }\n\n    .watch-controls {\n      .state-off {\n        img {\n          opacity: 0.3;\n        }\n      }\n      .state-on {\n        img {\n          opacity: 1.0;\n        }\n      }\n    }\n\n    .unviewed {\n      color: #fff;\n      &.error {\n        color: var(--color-watch-table-error);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_visualselector.scss",
    "content": "\n#selector-wrapper {\n  height: 100%;\n  text-align: center;\n  \n  max-height: 70vh;\n  overflow-y: scroll;\n  position: relative;\n\n  //width: 100%;\n  >img {\n    position: absolute;\n    z-index: 4;\n    max-width: 100%;\n  }\n\n  >canvas {\n    position: relative;\n    z-index: 5;\n    max-width: 100%;\n\n    &:hover {\n      cursor: pointer;\n    }\n  }\n}\n\n#selector-current-xpath {\n  font-size: 80%;\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_watch_table-mobile.scss",
    "content": "$grid-col-checkbox: 20px;\n$grid-col-watch: 100px;\n$grid-gap: 0.5rem;\n\n\n@media (max-width: 767px) {\n\n  /*\n  Max width before this PARTICULAR table gets nasty\n  This query will take effect for any screen smaller than 760px\n  and also iPads specifically.\n  */\n  .watch-table {\n    /* make headings work on mobile */\n    thead {\n      display: block;\n\n      tr {\n        th {\n          display: inline-block;\n          // Hide the \"Last\" text for smaller screens\n          @media (max-width: 768px) {\n            .hide-on-mobile {\n              display: none;\n            }\n          }\n        }\n      }\n\n      .empty-cell {\n        display: none;\n      }\n    }\n\n\n    .last-checked {\n      margin-left: calc($grid-col-checkbox + $grid-gap);\n\n      > span {\n        vertical-align: middle;\n      }\n    }\n\n    .last-changed {\n      margin-left: calc($grid-col-checkbox + $grid-gap);\n    }\n\n    .last-checked::before {\n      color: var(--color-text);\n      content: \"Last Checked \";\n    }\n\n    .last-changed::before {\n      color: var(--color-text);\n      content: \"Last Changed \";\n    }\n\n    /* Force table to not be like tables anymore */\n    td.inline {\n      display: inline-block;\n    }\n\n    .pure-table td,\n    .pure-table th {\n      border: none;\n    }\n\n    td {\n      /* Behave  like a \"row\" */\n      border: none;\n      border-bottom: 1px solid var(--color-border-watch-table-cell);\n      vertical-align: middle;\n\n      &:before {\n        /* Top/left values mimic padding */\n        top: 6px;\n        left: 6px;\n        width: 45%;\n        padding-right: 10px;\n        white-space: nowrap;\n      }\n    }\n\n    &.pure-table-striped {\n      tr {\n        background-color: var(--color-table-background);\n      }\n\n      tr:nth-child(2n-1) {\n        background-color: var(--color-table-stripe);\n      }\n\n      tr:nth-child(2n-1) td {\n        background-color: inherit;\n      }\n    }\n  }\n}\n\n@media (max-width: 767px) {\n  .watch-table {\n    tbody {\n      tr {\n        padding-bottom: 10px;\n        padding-top: 10px;\n        display: grid;\n        grid-template-columns: $grid-col-checkbox 1fr $grid-col-watch;\n        grid-template-rows: auto auto auto auto;\n        gap: $grid-gap;\n\n        .counter-i {\n          display: none;\n        }\n\n        td.checkbox-uuid {\n          display: grid;\n          place-items: center;\n        }\n\n        td.inline {\n          /* display: block !important;;*/\n        }\n\n        > td {\n          border-bottom: none;\n        }\n\n        // Empty state message - span full width on mobile\n        > td[colspan] {\n          grid-column: 1 / -1;\n        }\n\n        > td.title-col {\n          grid-column: 1 / -1;\n          grid-row: 1;\n          .watch-title {\n            font-size: 0.92rem;\n          }\n          .link-spread {\n            display: none;\n          }\n        }\n\n        > td.last-checked {\n          grid-column: 1 / -1;\n          grid-row: 2;\n        }\n\n        > td.last-changed {\n          grid-column: 1 / -1;\n          grid-row: 3;\n        }\n\n        > td.checkbox-uuid {\n          grid-column: 1;\n          grid-row: 4;\n        }\n\n        > td.buttons {\n          grid-column: 2;\n          grid-row: 4;\n          display: flex;\n          align-items: center;\n          justify-content: flex-start;\n        }\n\n        > td.watch-controls {\n          grid-column: 3;\n          grid-row: 4;\n          display: grid;\n          place-items: center;\n\n          a img {\n            padding: 10px;\n          }\n        }\n      }\n    }\n  }\n  .pure-table td {\n    padding: 3px !important;\n  }\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_watch_table.scss",
    "content": "/* table related */\n#stats_row {\n  display: flex;\n  align-items: center;\n  width: 100%;\n  color: #fff;\n  font-size: 0.85rem;\n  >* {\n    padding-bottom: 0.5rem;\n  }\n  .left {\n    text-align: left;\n  }\n\n  .right {\n    opacity: 0.5;\n    transition: opacity 0.6s ease;\n    margin-left: auto; /* pushes it to the far right */\n    text-align: right;\n  }\n}\nbody.has-queue {\n  #stats_row {\n    .right {\n      opacity: 1.0;\n    }\n  }\n}\n\n.watch-table {\n  width: 100%;\n  font-size: 80%;\n\n  tr {\n    &.unviewed {\n      font-weight: bold;\n    }\n\n    color: var(--color-watch-table-row-text);\n  }\n\n\n  td {\n    white-space: nowrap;\n\n    &.title-col {\n      word-break: break-all;\n      white-space: normal;\n    }\n\n    a.external::after {\n      content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);\n      margin: 0 3px 0 5px;\n    }\n\n  }\n\n\n  th {\n    white-space: nowrap;\n\n    a {\n      font-weight: normal;\n\n      &.active {\n        font-weight: bolder;\n      }\n\n      &.inactive {\n        .arrow {\n          display: none;\n        }\n      }\n    }\n  }\n\n  /* Row with 'checking-now' */\n  tr.checking-now {\n    td:first-child {\n      position: relative;\n    }\n\n    td:first-child::before {\n      content: \"\";\n      position: absolute;\n      top: 0;\n      bottom: 0;\n      left: 0;\n      width: 3px;\n      background-color: #293eff;\n    }\n\n    td.last-checked {\n      .spinner-wrapper {\n        display: inline-block !important;\n      }\n\n      .innertext {\n        display: none !important;\n      }\n    }\n  }\n\n  tr.queued {\n    a.recheck {\n      display: none !important;\n    }\n\n    a.already-in-queue-button {\n      display: inline-block !important;\n    }\n  }\n\n  tr.paused {\n    a.pause-toggle {\n      &.state-on {\n        display: inline !important;\n      }\n\n      &.state-off {\n        display: none !important;\n      }\n    }\n  }\n\n  tr.notification_muted {\n    a.mute-toggle {\n      &.state-on {\n        display: inline !important;\n      }\n\n      &.state-off {\n        display: none !important;\n      }\n    }\n  }\n\n\n  tr.has-error {\n    color: var(--color-watch-table-error);\n\n    .error-text {\n      display: block !important;\n    }\n  }\n\n  tr.single-history {\n    a.preview-link {\n      display: inline-block !important;\n    }\n  }\n\n  tr.multiple-history {\n    a.history-link {\n      display: inline-block !important;\n    }\n  }\n\n\n}\n\n#watch-table-wrapper {\n  /* general styling */\n  #post-list-buttons {\n    text-align: right;\n    padding: 0px;\n    margin: 0px;\n\n    li {\n      display: inline-block;\n    }\n\n    a {\n      border-top-left-radius: initial;\n      border-top-right-radius: initial;\n      border-bottom-left-radius: 5px;\n      border-bottom-right-radius: 5px;\n    }\n  }\n\n  /* post list dynamically on/off stuff */\n\n  &.has-error {\n    #post-list-buttons {\n      #post-list-with-errors {\n        display: inline-block !important;\n      }\n    }\n  }\n\n  &.has-unread-changes {\n    #post-list-buttons {\n      #post-list-unread, #post-list-mark-views, #post-list-unread {\n        display: inline-block !important;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "changedetectionio/static/styles/scss/parts/_widgets.scss",
    "content": "\n// Ternary radio button group component\n.ternary-radio-group {\n  display: flex;\n  gap: 0;\n  border: 1px solid var(--color-grey-750);\n  border-radius: 4px;\n  overflow: hidden;\n  width: fit-content;\n  background: var(--color-background);\n\n  .ternary-radio-option {\n    position: relative;\n    cursor: pointer;\n    margin: 0;\n    display: flex;\n    align-items: center;\n\n    input[type=\"radio\"] {\n      position: absolute;\n      opacity: 0;\n      width: 0;\n      height: 0;\n    }\n\n    .ternary-radio-label {\n      padding: 8px 16px;\n      background: var(--color-grey-900);\n      border: none;\n      border-right: 1px solid var(--color-grey-750);\n      font-size: 13px;\n      font-weight: 500;\n      color: var(--color-text);\n      transition: all 0.2s ease;\n      cursor: pointer;\n      display: block;\n      text-align: center;\n    }\n\n    &:last-child .ternary-radio-label {\n      border-right: none;\n    }\n\n    input:checked + .ternary-radio-label {\n      background: var(--color-link);\n      color: var(--color-text-button);\n      font-weight: 600;\n\n      &.ternary-default {\n        background: var(--color-grey-600);\n        color: var(--color-text-button);\n      }\n\n      &:hover {\n        background: #1a7bc4;\n\n        &.ternary-default {\n          background: var(--color-grey-500);\n        }\n      }\n    }\n\n    &:hover .ternary-radio-label {\n      background: var(--color-grey-800);\n    }\n  }\n\n  @media (max-width: 480px) {\n    width: 100%;\n\n    .ternary-radio-label {\n      flex: 1;\n      min-width: auto;\n    }\n  }\n}\n\n// Standard radio button styling\ninput[type=\"radio\"].pure-radio:checked + label,\ninput[type=\"radio\"].pure-radio:checked {\n  background: var(--color-link);\n  color: var(--color-text-button);\n}\n\nhtml[data-darkmode=\"true\"] {\n  .ternary-radio-group {\n    .ternary-radio-option {\n      .ternary-radio-label {\n        background: var(--color-grey-350);\n      }\n\n      &:hover .ternary-radio-label {\n        background: var(--color-grey-400);\n      }\n\n      input:checked + .ternary-radio-label {\n        background: var(--color-link);\n        color: var(--color-text-button);\n\n        &.ternary-default {\n          background: var(--color-grey-600);\n        }\n\n        &:hover {\n          background: #1a7bc4;\n\n          &.ternary-default {\n            background: var(--color-grey-500);\n          }\n        }\n      }\n    }\n  }\n}"
  },
  {
    "path": "changedetectionio/static/styles/scss/styles.scss",
    "content": "/*\n * -- BASE STYLES --\n */\n\n@use \"settings\" as *;\n@use \"parts/variables\";\n@use \"parts/arrows\";\n@use \"parts/browser-steps\";\n@use \"parts/extra_proxies\";\n@use \"parts/extra_browsers\";\n@use \"parts/pagination\";\n@use \"parts/spinners\";\n@use \"parts/darkmode\";\n@use \"parts/menu\";\n@use \"parts/love\";\n@use \"parts/preview_text_filter\";\n@use \"parts/watch_table\";\n@use \"parts/watch_table-mobile\";\n@use \"parts/edit\";\n@use \"parts/conditions_table\";\n@use \"parts/lister_extra\";\n@use \"parts/socket\";\n@use \"parts/visualselector\";\n@use \"parts/widgets\";\n@use \"parts/diff_image\";\n@use \"parts/modal\";\n@use \"parts/language\";\n@use \"parts/action_sidebar\";\n@use \"parts/hamburger_menu\";\n@use \"parts/search_modal\";\n@use \"parts/notification_bubble\";\n@use \"parts/toast\";\n@use \"parts/login_form\";\n@use \"parts/tabs\";\n\n// Smooth transitions for theme switching\nbody,\n.pure-table,\n.pure-table thead,\n.pure-table td,\n.pure-table th,\n.pure-form input,\n.pure-form textarea,\n.pure-form select,\n.edit-form .inner,\n.pure-menu-horizontal,\nfooter,\n.sticky-tab,\n#diff-jump,\n.button-tag,\n#new-watch-form,\n#new-watch-form input:not(.pure-button),\ncode,\n.messages li,\n#checkbox-operations,\n.inline-warning,\na,\n.watch-controls img {\n  transition: color 0.4s ease, background-color 0.4s ease, background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;\n}\n\nbody {\n  color: var(--color-text);\n  background: var(--color-background-page);\n  font-family: Helvetica Neue, Helvetica, Lucida Grande, Arial, Ubuntu, Cantarell, Fira Sans, sans-serif;\n}\n\n.visually-hidden {\n  clip: rect(0 0 0 0);\n  clip-path: inset(50%);\n  height: 1px;\n  overflow: hidden;\n  position: absolute;\n  white-space: nowrap;\n  width: 1px;\n}\n\n// Row icons like chrome, pdf, share, etc\n.status-icon {\n  display: inline-block;\n  height: 1rem;\n  vertical-align: middle;\n}\n\n.pure-table-even {\n  background: var(--color-background);\n}\n\n/* Some styles from https://css-tricks.com/ */\na {\n  text-decoration: none;\n  color: var(--color-link);\n}\n\na.github-link {\n  color: var(--color-icon-github);\n  margin: 0 1rem 0 0.5rem;\n\n  svg {\n    fill: currentColor;\n  }\n\n  &:hover {\n    color: var(--color-icon-github-hover);\n  }\n}\n\n#search-result-info {\n  color: #fff;\n}\n\nbutton.toggle-button {\n  vertical-align: middle;\n  background: transparent;\n  border: none;\n  cursor: pointer;\n\n  color: var(--color-icon-github);\n\n  &:hover {\n    color: var(--color-icon-github-hover);\n  }\n\n  svg {\n    fill: currentColor;\n  }\n\n  .icon-light {\n    display: block;\n  }\n\n\n}\n\n.pure-menu-horizontal {\n  background: var(--color-background);\n  padding: 5px;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n#pure-menu-horizontal-spinner {\n  height: 3px;\n  background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);\n  background-size: 400% 400%;\n  width: 100%;\n  animation: gradient 200s ease infinite;\n}\n\nbody.spinner-active {\n  #pure-menu-horizontal-spinner {\n    animation: gradient 1s ease infinite;\n  }\n}\n\n@keyframes gradient {\n\t0% {\n\t\tbackground-position: 0% 50%;\n\t}\n\t50% {\n\t\tbackground-position: 100% 50%;\n\t}\n\t100% {\n\t\tbackground-position: 0% 50%;\n\t}\n}\n.pure-menu-heading {\n  color: var(--color-text-menu-heading);\n}\n\n.pure-menu-link {\n  color: var(--color-text-menu-link);\n\n  &:hover {\n    background-color: var(--color-background-menu-link-hover);\n    color: var(--color-text-menu-link-hover);\n  }\n}\n\n\n.tab-pane-inner {\n  // .tab-pane-inner will have the #id that the tab button jumps/anchors to\n  scroll-margin-top: 200px;\n}\n\nsection.content {\n  @media only screen and (max-width: $desktop-wide-breakpoint) {\n    padding-top: 80px;\n  }\n  @media only screen and (min-width: $desktop-wide-breakpoint) {\n    padding-top: 100px;\n  }\n\n  padding-bottom: 1em;\n  flex-direction: column;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\ncode {\n  background: var(--color-background-code);\n  color: var(--color-text);\n}\n\n.inline-tag {\n  white-space: nowrap;\n  border-radius: 5px;\n  padding: 2px 5px;\n  margin-right: 4px;\n  line-height: 1.2rem;\n}\n\n/* Processor type badges - colors auto-generated from processor names */\n.processor-badge {\n  @extend .inline-tag;\n  font-weight: 900;\n}\n\n.watch-tag-list {\n  color: var(--color-white);\n  background: var(--color-text-watch-tag-list);\n  @extend .inline-tag;\n  \n  /* Remove default anchor styling when used as links */\n  text-decoration: none;\n  \n  &:hover {\n    text-decoration: none;\n    opacity: 0.8;\n    cursor: pointer;\n  }\n  \n  &:visited {\n    color: var(--color-white);\n  }\n}\n\n@media (min-width: 768px) {\n  .box {\n    margin: 0 1em !important;\n  }\n}\n\n.box {\n  max-width: 100%;\n  margin: 0 0.3em;\n  flex-direction: column;\n  display: flex;\n  justify-content: center;\n}\n\n\nbody:after {\n  content: \"\";\n  background: linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%);\n}\n\nbody:after,\nbody:before {\n  display: block;\n  height: 650px;\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  z-index: -1;\n}\n\nbody::after {\n  opacity: 0.91;\n}\n\nbody::before {\n  // background-image set in base.html so it works with reverse proxies etc\n  content: \"\";\n}\n\nbody:after,\nbody:before {\n  -webkit-clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%);\n  clip-path: polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%)\n}\n\n.button-small {\n  font-size: 85%;\n}\n\n.button-xsmall {\n  font-size: 70%;\n}\n\n.fetch-error {\n  padding-top: 1em;\n  font-size: 80%;\n  max-width: 400px;\n  display: block;\n}\n\n.pure-button-primary,\na.pure-button-primary,\n.pure-button-selected,\na.pure-button-selected {\n  background-color: var(--color-background-button-primary);\n}\n\n.button-secondary {\n  color: var(--color-text-button);\n  border-radius: 4px;\n  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);\n}\n\n.button-success {\n  background: var(--color-background-button-success);\n}\n\n.button-tag {\n  background: var(--color-background-button-tag);\n  color: var(--color-text-button);\n  font-size: 65%;\n  border-bottom-left-radius: initial;\n  border-bottom-right-radius: initial;\n  margin-right: 4px;\n  &.active {\n    background: var(--color-background-button-tag-active);\n    font-weight: bold;\n  }\n\n}\n\n.button-error {\n  background: var(--color-background-button-error);\n  color: var(--color-text-button-error);\n}\n\n.button-warning {\n  background: var(--color-background-button-warning);\n  color: var(--color-text-button-warning);\n}\n\n.button-secondary {\n  background: var(--color-background-button-secondary);\n}\n\n.button-cancel {\n  background: var(--color-background-button-cancel);\n}\n\n.messages {\n  li {\n    list-style: none;\n    padding: 1em;\n    border-radius: 10px;\n    color: var(--color-text-messages);\n    font-weight: bold;\n\n    &.message {\n      background: var(--color-background-messages-message);\n    }\n\n    &.error {\n      background: var(--color-background-messages-error);\n    }\n\n    &.notice {\n      background: var(--color-background-messages-notice);\n    }\n  }\n\n  &.with-share-link {\n    >*:hover {\n      cursor: pointer;\n    }\n  }\n}\n\n.notifications-wrapper {\n  padding-top: 0.5rem;\n  #notification-test-log {\n    margin-top: 1rem;\n    padding: 1rem;\n    white-space: pre-wrap;\n    word-break: break-word;\n    overflow-wrap: break-word;\n    max-width: 100%;\n    box-sizing: border-box;\n    max-height: 12rem;\n    overflow-y: scroll;\n    border: 1px solid var(--color-border-notification);\n    border-radius: 5px;\n\n  }\n}\n\nlabel {\n &:hover {\n   cursor: pointer;\n }  \n}\n\n.grey-form-border {\n  border: 1px solid var(--color-border-notification);\n  padding: 0.5rem;\n  border-radius: 5px;\n}\n\n#notification-error-log {\n  border: 1px solid var(--color-border-notification);\n  padding: 1rem;\n  border-radius: 5px;\n  overflow-wrap: break-word;\n}\n\n#token-table {\n\n  &.pure-table td,\n  &.pure-table th {\n    font-size: 80%;\n  }\n}\n\n// Some field colouring for transperant field\n.pure-form input[type=text].transparent-field {\n  background-color:  var(--color-background-new-watch-input-transparent) !important;\n  color: var(--color-white) !important;\n  border: 1px solid rgba(255, 255, 255, 0.2) !important;\n  box-shadow: none !important;\n  -webkit-box-shadow: none !important;\n  &::placeholder {\n    opacity: 0.5;\n    color: rgba(255, 255, 255, 0.7);\n    font-weight: lighter;\n  }\n}\n\n#new-watch-form {\n  background: var(--color-background-new-watch-form);\n  padding: 1em;\n  border-radius: 10px;\n  margin-bottom: 1em;\n  max-width: 100%;\n\n  #url {\n    &::placeholder {\n      font-weight: bold;\n    }\n  }\n\n  input {\n    display: inline-block;\n    margin-bottom: 5px;\n  }\n\n  input:not(.pure-button) {\n    background-color: var(--color-background-new-watch-input);\n    color: var(--color-text-new-watch-input);\n  }\n\n  .label {\n    display: none;\n  }\n\n  legend {\n    color: var(--color-text-legend);\n    font-weight: bold;\n  }\n\n\n  #watch-add-wrapper-zone {\n    @media only screen and (min-width: 760px) {\n      display: flex;\n      gap: 0.3rem;\n      flex-direction: row;\n      min-width: 70vw;\n    }\n    /* URL field grows always, other stay static in width */\n    > span {\n      flex-grow: 0;\n\n      input {\n        width: 100%;\n        padding-right: 1em;\n      }\n\n      &:first-child {\n        flex-grow: 1;\n      }\n    }\n\n    @media only screen and (max-width: 760px) {\n      #url {\n        width: 100%;\n      }\n    }\n  }\n\n  #watch-group-tag {\n    font-size: 0.9rem;\n    padding: 0.3rem;\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n    color: var(--color-white);\n    label, input {\n      margin: 0;\n    }\n\n    input {\n      flex: 1;\n    }\n  }\n}\n\n\n#diff-col {\n  padding-left: 40px;\n}\n\n#diff-jump {\n  position: fixed;\n  left: 0px;\n  top: 120px;\n  background: var(--color-background);\n  padding: 10px;\n  border-top-right-radius: 5px;\n  border-bottom-right-radius: 5px;\n  box-shadow: 1px 1px 4px var(--color-shadow-jump);\n\n  a {\n    color: var(--color-link);\n    cursor: pointer;\n    -moz-user-select: none;\n    -webkit-user-select: none;\n    -ms-user-select: none;\n    user-select: none;\n    -o-user-select: none;\n  }\n}\n\nfooter {\n  padding: 10px;\n  background: var(--color-background);\n  color: var(--color-text-footer);\n  text-align: center;\n}\n\n#feed-icon {\n  vertical-align: middle;\n}\n\n#top-right-menu {\n  // Just let flex overflow the x axis for now\n  /*\n      position: absolute;\n      right: 0px;\n      background: linear-gradient(to right, #fff0, #fff 10%);\n      padding-left: 20px;\n      padding-right: 10px;\n      */\n}\n\n.sticky-tab {\n  @media only screen and (max-width: $desktop-wide-breakpoint) {\n    display: none;\n  }\n  position: absolute;\n  top: 60px;\n  font-size: 65%;\n  background: var(--color-background);\n  padding: 10px;\n\n  &#left-sticky {\n    left: 0;\n    position: fixed;\n    border-top-right-radius: 5px;\n    border-bottom-right-radius: 5px;\n    box-shadow: 1px 1px 4px var(--color-shadow-jump);\n  }\n\n  &#right-sticky {\n    right: 0px;\n  }\n\n  &#hosted-sticky {\n    right: 0px;\n    top: 100px;\n    font-weight: bold;\n  }\n}\n\n#new-version-text a {\n  color: var(--color-link-new-version);\n}\n\n.watch-controls {\n  color: #f8321b;\n\n  .state-on {\n    img {\n      opacity: 0.8;\n    }\n  }\n\n  /* default */\n  img {\n    opacity: 0.2;\n  }\n\n  img {\n    &:hover {\n      transition: opacity 0.3s;\n      opacity: 0.8;\n    }\n  }\n}\n\n.monospaced-textarea {\n  textarea {\n    width: 100%;\n    font-family: monospace;\n    white-space: pre;\n    overflow-wrap: normal;\n    // No scrollbars until needed.\n    overflow-x: auto;\n  }\n}\n\n\n.pure-form {\n  fieldset {\n    padding-top: 0px;\n\n    ul {\n      padding-bottom: 0px;\n      margin-bottom: 0px;\n    }\n  }\n\n  .pure-control-group,\n  .pure-group,\n  .pure-controls {\n    padding-bottom: 1em;\n\n    div {\n      margin: 0px;\n    }\n\n    .checkbox {\n      >* {\n        display: inline;\n        vertical-align: middle;\n      }\n\n      >label {\n        padding-left: 5px;\n      }\n    }\n\n    legend {\n      color: var(--color-text-legend);\n    }\n  }\n\n  /* The input fields with errors */\n  .error {\n    input {\n      background-color: var(--color-error-input);\n    }\n  }\n\n  /* The list of errors */\n  ul.errors {\n    padding: .5em .6em;\n    border: 1px solid var(--color-error-list);\n    border-radius: 4px;\n    vertical-align: middle;\n    -webkit-box-sizing: border-box;\n    box-sizing: border-box;\n\n    li {\n      margin-left: 1em;\n      color: var(--color-error-list);\n    }\n  }\n\n  label {\n    font-weight: bold;\n  }\n\n  textarea {\n    width: 100%;\n  }\n\n  .inline-radio {\n    ul {\n      margin: 0px;\n      list-style: none;\n\n      li {\n        display: flex;\n        align-items: center;\n        gap: 1em;\n      }\n    }\n  }\n}\n\n\n@media only screen and (max-width: 760px),\n(min-device-width: 768px) and (max-device-width: $desktop-wide-breakpoint) {\n  .edit-form {\n    padding: 0.5em;\n    margin: 0;\n  }\n\n  #nav-menu {\n    overflow-x: scroll;\n  }\n}\n\n\n@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: $desktop-wide-breakpoint) {\n  input[type='text'] {\n    width: 100%;\n  }\n}\n\n.pure-table {\n  border-color: var(--color-border-table-cell);\n\n  thead {\n    background-color: var(--color-background-table-thead);\n    color: var(--color-text);\n    border-bottom: 1px solid var(--color-background-table-thead);\n  }\n\n  td,\n  th {\n    border-left-color: var(--color-border-table-cell);\n  }\n}\n\n.pure-table-striped {\n  tr:nth-child(2n-1) {\n    td {\n      background-color: var(--color-table-stripe);\n    }\n  }\n}\n\n.pure-form input[type=color],\n.pure-form input[type=date],\n.pure-form input[type=datetime-local],\n.pure-form input[type=datetime],\n.pure-form input[type=email],\n.pure-form input[type=month],\n.pure-form input[type=number],\n.pure-form input[type=password],\n.pure-form input[type=search],\n.pure-form input[type=tel],\n.pure-form input[type=text],\n.pure-form input[type=time],\n.pure-form input[type=url],\n.pure-form input[type=week],\n.pure-form select,\n.pure-form textarea {\n  border: var(--color-border-input);\n  box-shadow: inset 0 1px 3px var(--color-shadow-input);\n  background-color: var(--color-background-input);\n  color: var(--color-text-input);\n\n  &:active {\n    background-color: var(--color-background-input);\n  }\n}\n\ninput::placeholder,\ntextarea::placeholder {\n  color: var(--color-text-input-placeholder);\n}\n\n\n/** Desktop vs mobile input field strategy\n- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out\n- Rely always on width in CSS\n*/\n/** Set max width for input field */\n.m-d {\n  min-width: 100%;\n}\n\n@media only screen and (min-width: 761px) {\n\n  /* m-d is medium-desktop */\n  .m-d {\n    min-width: 80%;\n  }\n}\n\n\n$form-edge-padding: 20px;\n\n.pure-form-stacked {\n  >div:first-child {\n    display: block;\n  }\n}\n\n// Login form styles moved to parts/_login_form.scss\n\n.tab-pane-inner {\n\n  &:not(:target) {\n    display: none;\n  }\n\n  &:target {\n    display: block;\n  }\n\n  // doesnt need padding because theres another row of buttons/activity\n  padding: 0px;\n}\n\n.beta-logo {\n  height: 50px;\n  // looks better when it's hanging off a little\n  right: -3px;\n  top: -3px;\n  position: absolute;\n}\n\n#selector-header {\n  padding-bottom: 1em;\n}\n\nbody.full-width {\n  .edit-form {\n    width: 95%;\n  }\n}\n\n.edit-form {\n  min-width: 70%;\n  /* so it cant overflow */\n  max-width: 95%;\n\n  .box-wrap {\n    position: relative;\n  }\n\n  .inner {\n    background: var(--color-background);\n    padding: $form-edge-padding;\n  }\n\n  #actions {\n    display: block;\n    background: var(--color-background);\n  }\n\n  /* Make action buttons have consistent size and spacing */\n  #actions .pure-control-group {\n    display: flex;\n    gap: 0.625em;\n    flex-wrap: wrap;\n  }\n\n  .pure-form-message-inline {\n    padding-left: 0;\n    color: var(--color-text-input-description);\n    code {\n      font-size: .875em;\n    }\n  }\n}\n\n.border-fieldset {\n  h3 {\n    margin-top: 0;\n  }\n  border: 1px solid #ccc;\n  padding: 1rem;\n  border-radius: 5px;\n  margin-bottom: 1rem;\n  fieldset:last-of-type {\n    padding-bottom: 0;\n    .pure-control-group {\n      padding-bottom: 0;\n    }\n  }\n}\n\n\n\nul {\n  padding-left: 1em;\n  padding-top: 0px;\n  margin-top: 4px;\n}\n\n.time-check-widget {\n  tr {\n    display: inline;\n\n    input[type=\"number\"] {\n      width: 5em;\n    }\n  }\n}\n\n@media only screen and (max-width: 760px) {\n  .time-check-widget {\n    tbody {\n      display: grid;\n      grid-template-columns: auto 1fr auto 1fr;\n      gap: 0.625em 0.3125em;\n      align-items: center;\n    }    \n    tr {\n      display: contents; \n      th {\n        text-align: right;\n        padding-right: 5px;\n      }\n      input[type=\"number\"] {\n        width: 100%;\n        max-width: 5em;\n      }\n    }\n  }\n}\n\n#webdriver_delay {\n    width: 5em;\n}\n\n#api-key {\n  &:hover {\n    cursor: pointer;\n  }\n}\n\n#api-key-copy {\n  color: var(--color-api-key);\n}\n\n.button-green {\n  background-color: var(--color-background-button-green);\n}\n\n.button-red {\n  background-color: var(--color-background-button-red);\n}\n\n.noselect {\n  -webkit-touch-callout: none;\n  /* iOS Safari */\n  -webkit-user-select: none;\n  /* Safari */\n  -moz-user-select: none;\n  /* Old versions of Firefox */\n  -ms-user-select: none;\n  /* Internet Explorer/Edge */\n  user-select: none;\n  /* Non-prefixed version, currently\n    supported by Chrome, Edge, Opera and Firefox */\n}\n\n\n#checkbox-operations {\n  background: var(--color-background-checkbox-operations);\n  padding: 1em;\n  border-radius: 10px;\n  margin-bottom: 1em;\n  display: none;\n  button {\n    /* some space if they wrap the page */\n    margin-bottom: 3px;\n    margin-top: 3px;\n    /* vertically center icon and text */\n    display: inline-flex;\n    align-items: center;\n  }\n}\n\n.checkbox-uuid {\n  >* {\n    vertical-align: middle;\n  }\n}\n\n.inline-warning {\n  >span {\n    display: inline-block;\n    vertical-align: middle;\n  }\n\n  img.inline-warning-icon {\n    display: inline;\n    height: 26px;\n    vertical-align: middle;\n  }\n\n  border: 1px solid var(--color-border-warning);\n  padding: 0.5rem;\n  border-radius: 5px;\n  color: var(--color-warning);\n}\n\n/* automatic price following helpers */\n.tracking-ldjson-price-data {\n  background-color: var(--color-background-button-green);\n  color: #000;\n  opacity: 0.6;\n  @extend .inline-tag;\n}\n\n.ldjson-price-track-offer {\n  a.pure-button {\n    border-radius: 3px;\n    padding: 3px;\n    background-color: var(--color-background-button-green);\n  }\n\n  font-weight: bold;\n  font-style: italic;\n}\n\n.price-follow-tag-icon {\n  display: inline-block;\n  height: 0.8rem;\n  vertical-align: middle;\n}\n\n\n#quick-watch-processor-type {\n  ul#processor {\n    color: #fff;\n    padding-left: 0px;\n    li {\n      list-style: none;\n      font-size: 0.9rem;\n      display: grid;\n      grid-template-columns: auto 1fr;\n      align-items: center;\n      gap: 0.5rem;\n      margin-bottom: 0.5rem;\n    }\n  }\n  label, input {\n    padding: 0;\n    margin: 0;\n  }\n}\n\n.restock-label {\n  &.in-stock {\n    background-color: var(--color-background-button-green);\n    color: #fff;\n  }\n  &.not-in-stock {\n    background-color: var(--color-background-button-cancel);\n    color: #777;\n  }\n  &.error {\n    background-color: var(--color-background-button-error);\n    color: #fff;\n    opacity: 0.7;\n  }\n\n  svg {\n    vertical-align: middle;\n  }\n\n  @extend .inline-tag;\n}\n\n#chrome-extension-link {\n  img {\n    height: 21px;\n    padding: 2px;\n    vertical-align: middle;\n  }\n\n  padding: 9px;\n  border: 1px solid var(--color-grey-800);\n  border-radius: 10px;\n  vertical-align: middle;\n}\n\n#realtime-conn-error {\n  position: fixed;\n  bottom: 0;\n  left: 0;\n  background: var(--color-warning);\n  padding: 10px;\n  font-size: 0.8rem;\n  color: #fff;\n  opacity: 0.8;\n}\n\n#bottom-horizontal-offscreen {\n  position: fixed;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  width: 100%;\n  min-height: 50px;\n  max-height: 50vh; // Don't take more than 50% of viewport height\n  background: #ffffffb8;\n  border-top: 1px solid var(--color-border-table-cell);\n  padding: 10px;\n  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);\n  z-index: 100;\n  overflow-y: auto; // Allow scrolling if content exceeds max-height\n\n  // Smooth transition when shown/hidden\n  transition: opacity 0.3s ease-in-out;\n\n  // When JavaScript removes display:none, ensure it scrolls into view\n  scroll-margin-bottom: 10px;\n\n  // Center contents horizontally\n  display: flex;\n  justify-content: center;\n  align-items: center;\n}\n\nul#highlightSnippetActions {\n  list-style: none;\n  li {\n    display: inline-block;\n  }\n}\n\n\n"
  },
  {
    "path": "changedetectionio/static/styles/styles.css",
    "content": ":root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100);--highlight-trigger-text-bg-color: #1b98f8;--highlight-ignored-text-bg-color: var(--color-grey-700);--highlight-blocked-text-bg-color: rgb(202, 60, 60)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadow-jump: var(--color-grey-200);--color-icon-github: var(--color-white);--color-icon-github-hover: var(--color-grey-700);--color-watch-table-error: var(--color-light-red);--color-watch-table-row-text: var(--color-grey-800)}html[data-darkmode=true] .icon-spread{filter:hue-rotate(-10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .title-col a[target=_blank]::after,html[data-darkmode=true] .watch-table .current-diff-url::after{filter:invert(0.5) hue-rotate(10deg) brightness(2)}html[data-darkmode=true] .watch-table .status-browsersteps{filter:invert(0.5) hue-rotate(10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .watch-controls .state-off img{opacity:.3}html[data-darkmode=true] .watch-table .watch-controls .state-on img{opacity:1}html[data-darkmode=true] .watch-table .unviewed{color:#fff}html[data-darkmode=true] .watch-table .unviewed.error{color:var(--color-watch-table-error)}.arrow{border:solid #1b98f8;border-width:0 2px 2px 0;display:inline-block;padding:3px}.arrow.right{transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}.arrow.left{transform:rotate(135deg);-webkit-transform:rotate(135deg)}.arrow.up,.arrow.asc{transform:rotate(-135deg);-webkit-transform:rotate(-135deg)}.arrow.down,.arrow.desc{transform:rotate(45deg);-webkit-transform:rotate(45deg)}#browser_steps th{display:none}#browser_steps li{list-style:decimal;padding:5px}#browser_steps li.browser-step-with-error{background-color:#ffd6d6;border-radius:4px}#browser_steps li:not(:first-child):hover{opacity:1}#browser_steps li .control{padding-left:5px;padding-right:5px}#browser_steps li .control a{font-size:70%}#browser_steps li.empty{padding:0px;opacity:.35}#browser_steps li.empty .control{display:none}#browser_steps li:hover{background:#eee}#browser_steps li>label{display:none}@media only screen and (min-width: 760px){#browser-steps .flex-wrapper{display:flex;flex-flow:row;height:70vh;font-size:80%}#browser-steps .flex-wrapper #browser-steps-ui{flex-grow:1;flex-shrink:1;flex-basis:0;background-color:#eee;border-radius:5px}#browser-steps-fieldlist{flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:400px;padding-left:1rem;overflow-y:scroll}#browsersteps-selector-wrapper{height:100% !important}}#browsersteps-selector-wrapper{width:100%;overflow-y:scroll;position:relative;height:80vh}#browsersteps-selector-wrapper>img{position:absolute;max-width:100%}#browsersteps-selector-wrapper>canvas{position:relative;max-width:100%}#browsersteps-selector-wrapper>canvas:hover{cursor:pointer}#browsersteps-selector-wrapper .loader{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);z-index:100;max-width:350px;text-align:center}#browsersteps-selector-wrapper .spinner,#browsersteps-selector-wrapper .spinner:after{width:80px;height:80px;font-size:3px}#browsersteps-selector-wrapper #browsersteps-click-start{color:var(--color-grey-400)}#browsersteps-selector-wrapper #browsersteps-click-start:hover{cursor:pointer}ul#requests-extra_proxies{list-style:none}ul#requests-extra_proxies li>label{display:none}ul#requests-extra_proxies table tr{display:table-row}ul#requests-extra_proxies table tr input[type=text]{width:100%}@media only screen and (min-width: 1024px){ul#requests-extra_proxies table tr{display:inline}}#request label[for=proxy]{display:inline-block}body.proxy-check-active #request .proxy-check-details{font-size:80%;color:#555;display:block;padding-left:2em;max-width:500px}body.proxy-check-active #request .proxy-timing{font-size:80%;padding-left:1rem;color:var(--color-link)}#recommended-proxy{display:grid;gap:2rem;padding-bottom:1em}@media(min-width: 991px){#recommended-proxy{grid-template-columns:repeat(2, 1fr)}}#recommended-proxy>div{border:1px #aaa solid;border-radius:4px;padding:1em}#extra-proxies-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}ul#requests-extra_browsers{list-style:none}ul#requests-extra_browsers li>label{display:none}ul#requests-extra_browsers table tr{display:table-row}ul#requests-extra_browsers table tr input[type=text]{width:100%}@media only screen and (min-width: 1280px){ul#requests-extra_browsers table tr{display:inline}ul#requests-extra_browsers table tr input[type=text]{width:100%}}#extra-browsers-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}.pagination-page-info{text-transform:capitalize}.pagination.menu>*{display:inline-block}.pagination.menu li{display:inline-block}.pagination.menu a{padding:.65rem;margin:3px;border:none;background:#444;border-radius:2px;color:var(--color-text-button)}.pagination.menu a.disabled{display:none}.pagination.menu a.active{font-weight:bold;background:#888}.pagination.menu a:hover{background:#999}.spinner,.spinner:after{border-radius:50%;width:10px;height:10px}.spinner{margin:0px auto;font-size:3px;vertical-align:middle;display:inline-block;text-indent:-9999em;border-top:1.1em solid rgba(38,104,237,.2);border-right:1.1em solid rgba(38,104,237,.2);border-bottom:1.1em solid rgba(38,104,237,.2);border-left:1.1em solid #2668ed;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:load8 1.1s infinite linear;animation:load8 1.1s infinite linear}@-webkit-keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.toggle-light-mode .icon-dark{display:none}html[data-darkmode=true] .toggle-light-mode .icon-light{display:none}html[data-darkmode=true] .toggle-light-mode .icon-dark{display:block}.pure-menu-link{padding:.5rem 1em;line-height:1.2rem}#menu-mute,#menu-pause{padding-left:.3rem;padding-right:.3rem}#menu-mute img,#menu-pause img{height:1.2rem}.pure-menu-item svg{height:1.2rem}.pure-menu-item *{vertical-align:middle}.pure-menu-item .github-link{height:1.8rem;display:block}.pure-menu-item .github-link svg{height:100%}.pure-menu-item .bi-heart:hover{cursor:pointer}.pure-menu-item.active .pure-menu-link{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}#cdio-logo{padding-left:.5em}#inline-menu-extras-group>*{display:inline-block}#overlay{opacity:.95;position:fixed;width:350px;max-width:100%;height:100%;top:0;right:-350px;background-color:var(--color-table-stripe);z-index:2;transform:translateX(0);transition:transform .5s ease}#overlay.visible{transform:translateX(-100%)}#overlay .content{font-size:.875rem;padding:1rem;margin-top:5rem;max-width:400px;color:var(--color-watch-table-row-text)}#heartpath{transition:all ease .3s !important}#heartpath:hover{fill:red !important;transition:all ease .3s !important}.minitabs-wrapper{width:100%}.minitabs-wrapper>div[id]{padding:20px;border:1px solid #ccc;border-top:none}.minitabs-wrapper .minitabs-content{width:100%;display:flex}.minitabs-wrapper .minitabs-content>div{flex:1 1 auto;min-width:0;overflow:scroll}.minitabs-wrapper .minitabs{display:flex;border-bottom:1px solid #ccc}.minitabs-wrapper .minitab{flex:1;text-align:center;padding:12px 0;text-decoration:none;color:#333;background-color:#f1f1f1;border:1px solid #ccc;border-bottom:none;cursor:pointer;transition:background-color .3s}.minitabs-wrapper .minitab:hover{background-color:#ddd}.minitabs-wrapper .minitab.active{background-color:#fff;font-weight:bold}@media(min-width: 800px){body.preview-text-enabled #filters-and-triggers>div{display:flex;gap:20px;position:relative}}body.preview-text-enabled #edit-text-filter,body.preview-text-enabled #text-preview{flex:1;align-self:flex-start}body.preview-text-enabled #edit-text-filter #pro-tips{display:none}body.preview-text-enabled #text-preview{position:sticky;top:20px;padding-top:1rem;padding-bottom:1rem;display:block !important}body.preview-text-enabled #activate-text-preview{background-color:var(--color-grey-500)}body.preview-text-enabled .monospace-preview{background:var(--color-background-input);border:1px solid var(--color-grey-600);padding:1rem;color:var(--color-text-input);font-family:\"Courier New\",Courier,monospace;font-size:70%;word-break:break-word;white-space:pre-wrap}#activate-text-preview{right:0;position:absolute;z-index:3;box-shadow:1px 1px 4px var(--color-shadow-jump)}#stats_row{display:flex;align-items:center;width:100%;color:#fff;font-size:.85rem}#stats_row>*{padding-bottom:.5rem}#stats_row .left{text-align:left}#stats_row .right{opacity:.5;transition:opacity .6s ease;margin-left:auto;text-align:right}body.has-queue #stats_row .right{opacity:1}.watch-table{width:100%;font-size:80%}.watch-table tr{color:var(--color-watch-table-row-text)}.watch-table tr.unviewed{font-weight:bold}.watch-table td{white-space:nowrap}.watch-table td.title-col{word-break:break-all;white-space:normal}.watch-table td a.external::after{content:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);margin:0 3px 0 5px}.watch-table th{white-space:nowrap}.watch-table th a{font-weight:normal}.watch-table th a.active{font-weight:bolder}.watch-table th a.inactive .arrow{display:none}.watch-table tr.checking-now td:first-child{position:relative}.watch-table tr.checking-now td:first-child::before{content:\"\";position:absolute;top:0;bottom:0;left:0;width:3px;background-color:#293eff}.watch-table tr.checking-now td.last-checked .spinner-wrapper{display:inline-block !important}.watch-table tr.checking-now td.last-checked .innertext{display:none !important}.watch-table tr.queued a.recheck{display:none !important}.watch-table tr.queued a.already-in-queue-button{display:inline-block !important}.watch-table tr.paused a.pause-toggle.state-on{display:inline !important}.watch-table tr.paused a.pause-toggle.state-off{display:none !important}.watch-table tr.notification_muted a.mute-toggle.state-on{display:inline !important}.watch-table tr.notification_muted a.mute-toggle.state-off{display:none !important}.watch-table tr.has-error{color:var(--color-watch-table-error)}.watch-table tr.has-error .error-text{display:block !important}.watch-table tr.single-history a.preview-link{display:inline-block !important}.watch-table tr.multiple-history a.history-link{display:inline-block !important}#watch-table-wrapper #post-list-buttons{text-align:right;padding:0px;margin:0px}#watch-table-wrapper #post-list-buttons li{display:inline-block}#watch-table-wrapper #post-list-buttons a{border-top-left-radius:initial;border-top-right-radius:initial;border-bottom-left-radius:5px;border-bottom-right-radius:5px}#watch-table-wrapper.has-error #post-list-buttons #post-list-with-errors{display:inline-block !important}#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-mark-views,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread{display:inline-block !important}@media(max-width: 767px){.watch-table thead{display:block}.watch-table thead tr th{display:inline-block}}@media(max-width: 767px)and (max-width: 768px){.watch-table thead tr th .hide-on-mobile{display:none}}@media(max-width: 767px){.watch-table thead .empty-cell{display:none}.watch-table .last-checked{margin-left:calc(20px + .5rem)}.watch-table .last-checked>span{vertical-align:middle}.watch-table .last-changed{margin-left:calc(20px + .5rem)}.watch-table .last-checked::before{color:var(--color-text);content:\"Last Checked \"}.watch-table .last-changed::before{color:var(--color-text);content:\"Last Changed \"}.watch-table td.inline{display:inline-block}.watch-table .pure-table td,.watch-table .pure-table th{border:none}.watch-table td{border:none;border-bottom:1px solid var(--color-border-watch-table-cell);vertical-align:middle}.watch-table td:before{top:6px;left:6px;width:45%;padding-right:10px;white-space:nowrap}.watch-table.pure-table-striped tr{background-color:var(--color-table-background)}.watch-table.pure-table-striped tr:nth-child(2n-1){background-color:var(--color-table-stripe)}.watch-table.pure-table-striped tr:nth-child(2n-1) td{background-color:inherit}}@media(max-width: 767px){.watch-table tbody tr{padding-bottom:10px;padding-top:10px;display:grid;grid-template-columns:20px 1fr 100px;grid-template-rows:auto auto auto auto;gap:.5rem}.watch-table tbody tr .counter-i{display:none}.watch-table tbody tr td.checkbox-uuid{display:grid;place-items:center}.watch-table tbody tr>td{border-bottom:none}.watch-table tbody tr>td[colspan]{grid-column:1/-1}.watch-table tbody tr>td.title-col{grid-column:1/-1;grid-row:1}.watch-table tbody tr>td.title-col .watch-title{font-size:.92rem}.watch-table tbody tr>td.title-col .link-spread{display:none}.watch-table tbody tr>td.last-checked{grid-column:1/-1;grid-row:2}.watch-table tbody tr>td.last-changed{grid-column:1/-1;grid-row:3}.watch-table tbody tr>td.checkbox-uuid{grid-column:1;grid-row:4}.watch-table tbody tr>td.buttons{grid-column:2;grid-row:4;display:flex;align-items:center;justify-content:flex-start}.watch-table tbody tr>td.watch-controls{grid-column:3;grid-row:4;display:grid;place-items:center}.watch-table tbody tr>td.watch-controls a img{padding:10px}.pure-table td{padding:3px !important}}ul#conditions_match_logic{list-style:none}ul#conditions_match_logic input,ul#conditions_match_logic label,ul#conditions_match_logic li{display:inline-block}ul#conditions_match_logic li{padding-right:1em}.fieldlist_formfields{width:100%;background-color:var(--color-background, #fff);border-radius:4px;border:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header{display:flex;background-color:var(--color-background-table-thead, #e0e0e0);font-weight:bold;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header-cell{flex:1;padding:.5em 1em;text-align:left}.fieldlist_formfields .fieldlist-header-cell:last-child{flex:0 0 120px}.fieldlist_formfields .fieldlist-body{display:flex;flex-direction:column}.fieldlist_formfields .fieldlist-row{display:flex;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-row:last-child{border-bottom:none}.fieldlist_formfields .fieldlist-row:nth-child(2n-1){background-color:var(--color-table-stripe, #f2f2f2)}.fieldlist_formfields .fieldlist-row.error-row{background-color:var(--color-error-input, #ffdddd)}.fieldlist_formfields .fieldlist-cell{flex:1;padding:.5em 1em;display:flex;flex-direction:column;justify-content:center}.fieldlist_formfields .fieldlist-cell input,.fieldlist_formfields .fieldlist-cell select{width:100%}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:0 0 120px;display:flex;flex-direction:row;align-items:center;gap:4px}.fieldlist_formfields ul.errors{margin-top:.5em;margin-bottom:0;padding:.5em;background-color:var(--color-error-background-snapshot-age, #ffdddd);border-radius:4px;list-style-position:inside}@media only screen and (max-width: 760px){.fieldlist_formfields .fieldlist-header,.fieldlist_formfields .fieldlist-row{flex-direction:column}.fieldlist_formfields .fieldlist-header-cell{display:none}.fieldlist_formfields .fieldlist-row{padding:.5em 0;border-bottom:2px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-cell{padding:.25em .5em}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:1;justify-content:flex-start;padding-top:.5em}.fieldlist_formfields .fieldlist-cell:not(:last-child){margin-bottom:.5em}.fieldlist_formfields .fieldlist-cell::before{content:attr(data-label);font-weight:bold;margin-bottom:.25em}}.fieldlist_formfields .addRuleRow,.fieldlist_formfields .removeRuleRow,.fieldlist_formfields .verifyRuleRow{cursor:pointer;border:none;padding:4px 8px;border-radius:3px;font-weight:bold;background-color:#aaa;color:var(--color-foreground-text, #fff)}.fieldlist_formfields .addRuleRow:hover,.fieldlist_formfields .removeRuleRow:hover,.fieldlist_formfields .verifyRuleRow:hover{background-color:#999}.watch-table.favicon-not-enabled tr .favicon{display:none}.watch-table tr td.inline.title-col .flex-wrapper{display:flex;align-items:center;gap:4px}.watch-table td,.watch-table th{vertical-align:middle}.watch-table tr.has-favicon.unviewed img.favicon{opacity:1 !important}.watch-table .status-icons{white-space:nowrap;display:flex;align-items:center;gap:4px}.watch-table .status-icons>*{vertical-align:middle}.title-col{padding:10px}.title-wrapper{display:flex;align-items:center;gap:10px}.title-col-inner{display:inline-block;vertical-align:middle}.watch-table img.favicon{vertical-align:middle;max-width:25px;max-height:25px;height:25px;padding-right:4px}body.checking-now #checking-now-fixed-tab{display:block !important}#checking-now-fixed-tab{background:#ccc;border-radius:5px;bottom:0;color:var(--color-text);display:none;font-size:.8rem;left:0;padding:5px;position:fixed}#selector-wrapper{height:100%;text-align:center;max-height:70vh;overflow-y:scroll;position:relative}#selector-wrapper>img{position:absolute;z-index:4;max-width:100%}#selector-wrapper>canvas{position:relative;z-index:5;max-width:100%}#selector-wrapper>canvas:hover{cursor:pointer}#selector-current-xpath{font-size:80%}.ternary-radio-group{display:flex;gap:0;border:1px solid var(--color-grey-750);border-radius:4px;overflow:hidden;width:fit-content;background:var(--color-background)}.ternary-radio-group .ternary-radio-option{position:relative;cursor:pointer;margin:0;display:flex;align-items:center}.ternary-radio-group .ternary-radio-option input[type=radio]{position:absolute;opacity:0;width:0;height:0}.ternary-radio-group .ternary-radio-option .ternary-radio-label{padding:8px 16px;background:var(--color-grey-900);border:none;border-right:1px solid var(--color-grey-750);font-size:13px;font-weight:500;color:var(--color-text);transition:all .2s ease;cursor:pointer;display:block;text-align:center}.ternary-radio-group .ternary-radio-option:last-child .ternary-radio-label{border-right:none}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button);font-weight:600}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600);color:var(--color-text-button)}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}.ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-800)}@media(max-width: 480px){.ternary-radio-group{width:100%}.ternary-radio-group .ternary-radio-label{flex:1;min-width:auto}}input[type=radio].pure-radio:checked+label,input[type=radio].pure-radio:checked{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option .ternary-radio-label{background:var(--color-grey-350)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-400)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}body.processor-image_ssim_diff #edit-text-filter .text-filtering{display:none}body.processor-image_ssim_diff #conditions-tab{display:none}.modal-dialog{border:none;border-radius:10px;padding:0;background:var(--color-background);color:var(--color-text);box-shadow:0 5px 20px rgba(0,0,0,.3);max-width:500px;width:90%}.modal-dialog::backdrop{background:rgba(0,0,0,.6);backdrop-filter:blur(3px);animation:fadeIn .2s ease-out}.modal-dialog[open]{animation:slideIn .25s ease-out}.modal-dialog .modal-header{padding:1.5rem;border-bottom:1px solid var(--color-border-table-cell);display:flex;align-items:center;gap:1rem}.modal-dialog .modal-header .modal-icon{font-size:2rem;line-height:1;flex-shrink:0}.modal-dialog .modal-header .modal-icon.warning{color:var(--color-warning)}.modal-dialog .modal-header .modal-icon.danger{color:var(--color-background-button-error)}.modal-dialog .modal-header .modal-icon.info{color:var(--color-background-button-primary)}.modal-dialog .modal-header .modal-title{font-size:1.3rem;font-weight:bold;margin:0;color:var(--color-text)}.modal-dialog .modal-body{padding:1.5rem;line-height:1.6}.modal-dialog .modal-body p{margin:0 0 1rem 0}.modal-dialog .modal-body p:last-child{margin-bottom:0}.modal-dialog .modal-body strong{color:var(--color-text);font-weight:600}.modal-dialog .modal-footer{padding:1rem 1.5rem;border-top:1px solid var(--color-border-table-cell);display:flex;gap:.75rem;justify-content:flex-end;background:var(--color-grey-900)}.modal-dialog .modal-footer button{padding:.6rem 1.5rem;border:none;border-radius:4px;cursor:pointer;font-weight:500;transition:all .2s ease;font-size:.95rem}.modal-dialog .modal-footer button:hover{transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,.15)}.modal-dialog .modal-footer button:active{transform:translateY(0)}.modal-dialog .modal-footer button.modal-btn-cancel{background:var(--color-background-button-cancel);color:var(--color-grey-200)}.modal-dialog .modal-footer button.modal-btn-cancel:hover{background:var(--color-grey-700)}.modal-dialog .modal-footer button.modal-btn-confirm{background:var(--color-background-button-primary);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-confirm:hover{opacity:.9}.modal-dialog .modal-footer button.modal-btn-danger{background:var(--color-background-button-error);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-danger:hover{background:var(--color-dark-red)}.modal-dialog .modal-footer button.modal-btn-warning{background:var(--color-background-button-warning);color:var(--color-white)}.modal-dialog .modal-footer button.modal-btn-warning:hover{opacity:.9}html[data-darkmode=true] .modal-dialog{box-shadow:0 5px 30px rgba(0,0,0,.7)}html[data-darkmode=true] .modal-dialog .modal-footer{background:var(--color-grey-200)}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideIn{from{opacity:0;transform:translateY(-20px) scale(0.95)}to{opacity:1;transform:translateY(0) scale(1)}}@media only screen and (max-width: 760px){.modal-dialog{width:95%;max-width:none}.modal-dialog .modal-header{padding:1rem}.modal-dialog .modal-header .modal-title{font-size:1.1rem}.modal-dialog .modal-body{padding:1rem;font-size:.95rem}.modal-dialog .modal-footer{padding:.75rem 1rem;flex-wrap:wrap}.modal-dialog .modal-footer button{flex:1;min-width:120px}}#language-selector-flag{display:inline-block;width:1.2em;height:1.2em;vertical-align:middle;border-radius:50%;overflow:hidden;opacity:.6}#language-selector-flag:hover{opacity:1}.language-list{display:flex;flex-direction:column;gap:.5rem;padding:.5rem 0}.language-option{display:flex;align-items:center;gap:1rem;padding:.25rem;border-radius:4px;transition:background-color .2s ease;text-decoration:none;color:var(--color-text);border:1px solid rgba(0,0,0,0)}.language-option:hover{background-color:var(--color-background-menu-link-hover);border-color:var(--color-border-table-cell)}.language-option.active{background-color:var(--color-link);color:var(--color-text-button);font-weight:600}.language-option .flag{font-size:1.5rem;flex-shrink:0}.language-option .language-name{flex-grow:1;font-size:1rem}#language-modal .language-list .lang-option{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;margin-right:.5em;border-radius:50%;overflow:hidden}.content-wrapper{display:flex;gap:0;width:100%;max-width:100%;position:relative}@media only screen and (max-width: 900px){.content-wrapper{flex-direction:column}}.action-sidebar{position:sticky;top:100px;flex-shrink:0;width:80px;height:fit-content;background:rgba(0,0,0,0);padding:1.5rem 0;display:flex;flex-direction:column;gap:.5rem;align-items:center;z-index:0}@media only screen and (max-width: 900px){.action-sidebar{position:relative;top:0;width:100%;flex-direction:row;justify-content:space-around;padding:0;overflow-x:auto}}.action-sidebar-item{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;padding:.75rem .5rem;min-width:64px;text-decoration:none;opacity:.8;transition:opacity .2s ease}.action-sidebar-item:hover{opacity:1}.action-sidebar-item.active{opacity:1}.action-sidebar-item.active .action-icon{stroke:#fff;stroke-width:2.5}.action-sidebar-item.active .action-label{color:#fff;font-weight:700}.action-icon{width:28px;height:28px;stroke:#fff;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s ease}.action-label{font-size:.65rem;font-weight:500;text-align:center;line-height:1.1;letter-spacing:.02em;text-transform:uppercase;color:#fff;transition:color .2s ease;max-width:60px;word-wrap:break-word}.content-main{flex:0 1 auto;width:100%;min-width:0;padding:0;display:flex;flex-direction:column;align-items:center}.hamburger-menu{display:none;background:rgba(0,0,0,0);border:none;cursor:pointer;padding:.5rem;z-index:10001;position:relative}@media only screen and (max-width: 980px){.hamburger-menu{display:flex;flex-direction:column;justify-content:center;align-items:center}}.hamburger-icon{width:24px;height:20px;position:relative;display:flex;flex-direction:column;justify-content:space-between}.hamburger-icon span{display:block;height:3px;width:100%;background:var(--color-text);border-radius:2px;transition:all .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);transform-origin:center}.hamburger-menu.active .hamburger-icon span:nth-child(1){transform:translateY(8.5px) rotate(45deg)}.hamburger-menu.active .hamburger-icon span:nth-child(2){opacity:0;transform:translateX(-10px)}.hamburger-menu.active .hamburger-icon span:nth-child(3){transform:translateY(-8.5px) rotate(-45deg)}.mobile-menu-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:9999;opacity:0;transition:opacity .3s ease}.mobile-menu-overlay.active{display:block;opacity:1}.mobile-menu-drawer{position:fixed;top:0;right:-280px;width:280px;height:100%;background:var(--color-background);opacity:1;box-shadow:-2px 0 8px rgba(0,0,0,.15);z-index:10000;transition:right .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);overflow-y:auto;padding-top:60px}.mobile-menu-drawer.active{right:0}.mobile-menu-drawer .mobile-menu-items{list-style:none;padding:1rem 0;margin:0}.mobile-menu-drawer .mobile-menu-items li{border-bottom:1px solid var(--color-border-table-cell)}.mobile-menu-drawer .mobile-menu-items li>*{display:block;padding:1rem 1.5rem;color:var(--color-text);text-decoration:none;font-weight:500;transition:background .2s ease}.mobile-menu-drawer .mobile-menu-items li>*:hover{background:var(--color-background-menu-link-hover)}.mobile-menu-drawer .mobile-menu-items li#menu-pause,.mobile-menu-drawer .mobile-menu-items li#menu-mute{display:none}.logo-cdio{font-weight:bold;font-size:1.1rem}.logo-cdio .logo-cd{color:var(--color-grey-500)}.logo-cdio .logo-io{color:var(--color-text)}.menu-always-visible{display:flex;align-items:center;gap:.5rem;margin-left:auto}@media only screen and (max-width: 980px){#top-right-menu .menu-collapsible{display:none !important}.pure-menu-horizontal{overflow-x:visible !important}#nav-menu{overflow-x:visible !important}}@media only screen and (min-width: 1025px){.hamburger-menu,.mobile-menu-drawer,.mobile-menu-overlay{display:none !important}}html[data-darkmode=true] .mobile-menu-drawer{box-shadow:-2px 0 8px rgba(0,0,0,.4)}#search-modal .modal-body{padding:2rem 1.5rem}#search-modal .modal-body .pure-control-group{padding-bottom:0}#search-modal .modal-body .pure-control-group label{display:block;margin-bottom:.5rem;font-size:.9rem;font-weight:600;color:var(--color-text)}#search-modal .modal-body .pure-control-group #search-modal-input{width:100%;max-width:100%;box-sizing:border-box;padding:.6rem .8rem;font-size:1rem;border:1px solid var(--color-border-input);border-radius:4px;background-color:var(--color-background-input);color:var(--color-text-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);transition:border-color .2s ease,box-shadow .2s ease}#search-modal .modal-body .pure-control-group #search-modal-input:focus{outline:none;border-color:var(--color-link);box-shadow:0 0 0 3px rgba(27,152,248,.1)}#search-modal .modal-body .pure-control-group #search-modal-input::placeholder{color:var(--color-text-input-placeholder);opacity:.7}html[data-darkmode=true] #search-modal #search-modal-input:focus{box-shadow:0 0 0 3px rgba(89,189,251,.15)}.action-sidebar-item{position:relative}.action-sidebar-item .notification-bubble{position:absolute;top:8px;left:8px;min-width:18px;height:18px;background:#f44;color:#fff;font-size:10px;font-weight:700;line-height:18px;text-align:center;border-radius:9px;padding:0 2px;box-shadow:0 2px 4px rgba(0,0,0,.3);pointer-events:none;transition:all .2s ease;display:none}.action-sidebar-item .notification-bubble.red-bubble{background:#f44}.action-sidebar-item .notification-bubble.blue-bubble{background:#4a9eff;color:#fff}.action-sidebar-item .notification-bubble.visible{display:block}.action-sidebar-item .notification-bubble.pulse{animation:bubblePulse .4s ease-out}.action-sidebar-item .notification-bubble.large-number{font-size:8px;min-width:20px;height:20px;line-height:20px;border-radius:10px}@keyframes bubblePulse{0%{transform:scale(1)}50%{transform:scale(1.3)}100%{transform:scale(1)}}html[data-darkmode=true] .notification-bubble{box-shadow:0 2px 6px rgba(0,0,0,.6)}.toast-container{position:fixed;display:flex;flex-direction:column;gap:.75rem;pointer-events:none;z-index:10000}.toast-container.toast-top-right{top:20px;right:20px}.toast-container.toast-top-center{top:100px;left:50%;transform:translateX(-50%)}.toast-container.toast-top-left{top:20px;left:20px}.toast-container.toast-bottom-right{bottom:20px;right:20px}.toast-container.toast-bottom-center{bottom:20px;left:50%;transform:translateX(-50%)}.toast-container.toast-bottom-left{bottom:20px;left:20px}.toast{position:relative;display:flex;align-items:center;gap:.75rem;min-width:300px;max-width:500px;padding:1rem 1.25rem;background:var(--color-background);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15),0 0 0 1px rgba(0,0,0,.05);pointer-events:auto;overflow:hidden;opacity:0;transform:translateY(-50px);transition:all .3s cubic-bezier(0.68, -0.55, 0.265, 1.55);font-family:inherit}.toast.toast-show{opacity:1;transform:translateY(0)}.toast.toast-hide{opacity:0;transform:translateY(-50px) scale(0.95)}.toast.toast-success{border-left:4px solid #10b981}.toast.toast-success .toast-icon{color:#10b981}.toast.toast-error{border-left:4px solid #ef4444}.toast.toast-error .toast-icon{color:#ef4444}.toast.toast-warning{border-left:4px solid #f59e0b}.toast.toast-warning .toast-icon{color:#f59e0b}.toast.toast-info{border-left:4px solid #3b82f6}.toast.toast-info .toast-icon{color:#3b82f6}.toast.toast-default{border-left:4px solid var(--color-grey-500)}.toast-icon{flex-shrink:0;width:24px;height:24px}.toast-icon svg{width:100%;height:100%}.toast-message{flex:1;font-size:.875rem;line-height:1.5;color:var(--color-text);word-break:break-word;font-family:inherit}.toast-close{flex-shrink:0;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0);border:none;border-radius:4px;color:var(--color-grey-500);font-size:1.5rem;line-height:1;cursor:pointer;transition:all .2s ease;padding:0;margin-left:.25rem}.toast-close:hover{background:var(--color-grey-800);color:var(--color-text)}.toast-close:active{transform:scale(0.95)}.toast-progress{position:absolute;bottom:0;left:0;right:0;height:3px;background:currentColor;opacity:.3;transform-origin:left;transition:transform linear}html[data-darkmode=true] .toast{background:var(--color-grey-300);box-shadow:0 4px 12px rgba(0,0,0,.4),0 0 0 1px hsla(0,0%,100%,.05)}html[data-darkmode=true] .toast-close:hover{background:var(--color-grey-400)}@media only screen and (max-width: 768px){.toast-container{left:50% !important;right:auto !important;top:80px !important;transform:translateX(-50%) !important;align-items:center}.toast-container.toast-bottom-right,.toast-container.toast-bottom-center,.toast-container.toast-bottom-left{top:auto !important;bottom:80px !important}.toast{min-width:auto;max-width:none;width:80vw;transform:translateY(-100px)}.toast.toast-show{transform:translateY(0)}.toast.toast-hide{transform:translateY(-100px) scale(0.95)}}@media(prefers-reduced-motion: reduce){.toast{transition:opacity .2s ease;transform:none !important}.toast.toast-show{opacity:1}.toast.toast-hide{opacity:0}}.login-form{min-height:52vh;display:flex;align-items:center;justify-content:center;padding:2rem 1rem}.login-form .inner{background:var(--color-background);border-radius:16px;box-shadow:0 10px 40px rgba(0,0,0,.08),0 2px 8px rgba(0,0,0,.04);padding:3rem 2.5rem;width:100%;max-width:420px;position:relative;overflow:hidden;transition:transform .3s ease,box-shadow .3s ease}.login-form .inner:hover{box-shadow:0 15px 50px rgba(0,0,0,.12),0 5px 15px rgba(0,0,0,.06)}.login-form form{margin:0}.login-form fieldset{border:none;padding:0;margin:0}.login-form .pure-control-group{margin-bottom:1.75rem}.login-form .pure-control-group:last-of-type{margin-bottom:0;margin-top:2rem}.login-form label{display:block;margin-bottom:.5rem;font-weight:600;font-size:.9rem;color:var(--color-text);letter-spacing:.01em}.login-form input[type=password]{width:100%;padding:.875rem 1rem;border:2px solid var(--color-grey-800);border-radius:8px;font-size:1rem;background:var(--color-background-input);color:var(--color-text-input);transition:all .2s ease;box-sizing:border-box}.login-form input[type=password]:focus{outline:none;border-color:var(--color-link);box-shadow:0 0 0 3px rgba(27,152,248,.1);transform:translateY(-1px)}.login-form input[type=password]::placeholder{color:var(--color-text-input-placeholder)}.login-form button[type=submit]{width:100%;padding:.875rem 1.5rem;font-size:1rem;font-weight:600;border-radius:8px;border:none;background:var(--color-background-button-primary);color:var(--color-text-button);cursor:pointer;transition:all .2s ease;box-shadow:0 2px 8px rgba(27,152,248,.2)}.login-form button[type=submit]:hover{box-shadow:0 4px 12px rgba(27,152,248,.3);background:#06c}.login-form button[type=submit]:active{transform:translateY(0);box-shadow:0 2px 4px rgba(27,152,248,.2)}.content-main>ul.messages{position:fixed;top:120px;left:50%;transform:translateX(-50%);list-style:none;padding:0;margin:0;z-index:1000;min-width:300px;max-width:500px}.content-main>ul.messages li{padding:1rem 1.25rem;border-radius:8px;font-size:.95rem;line-height:1.5;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,.15);animation:slideDown .3s ease-out;border:2px solid rgba(0,0,0,0)}.content-main>ul.messages li.error{background:#fee;border:2px solid #ef4444;color:#991b1b;font-weight:600}.content-main>ul.messages li.success{background:#f0fdf4;border:2px solid #10b981;color:#166534}.content-main>ul.messages li.info,.content-main>ul.messages li.message{background:#eff6ff;border:2px solid #3b82f6;color:#1e40af}@keyframes slideDown{from{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}html[data-darkmode=true] .login-form .inner{box-shadow:0 10px 40px rgba(0,0,0,.4),0 2px 8px rgba(0,0,0,.2)}html[data-darkmode=true] .login-form .inner:hover{box-shadow:0 15px 50px rgba(0,0,0,.5),0 5px 15px rgba(0,0,0,.3)}html[data-darkmode=true] .login-form input[type=password]{border-color:var(--color-grey-400)}html[data-darkmode=true] .login-form input[type=password]:focus{border-color:var(--color-link)}html[data-darkmode=true] .content-main>ul.messages li{box-shadow:0 4px 12px rgba(0,0,0,.4)}html[data-darkmode=true] .content-main>ul.messages li.error{background:#4a1d1d;border-color:#ef4444;color:#fca5a5}html[data-darkmode=true] .content-main>ul.messages li.success{background:#1a3a2a;border-color:#10b981;color:#86efac}html[data-darkmode=true] .content-main>ul.messages li.info,html[data-darkmode=true] .content-main>ul.messages li.message{background:#1e3a5f;border-color:#3b82f6;color:#93c5fd}@media only screen and (max-width: 768px){.login-form{min-height:auto;padding:1rem .5rem;padding-top:5rem}.login-form .inner{padding:2rem 1.5rem;border-radius:12px}.content-main>ul.messages{top:70px;left:10px;right:10px;transform:none;min-width:auto}}body.wrapped-tabs .tabs ul{grid-template-columns:repeat(auto-fill, minmax(var(--tab-width, 180px), 1fr));grid-auto-flow:row;grid-auto-columns:unset;gap:0;column-gap:5px}body.wrapped-tabs .tabs ul li{border-radius:0}.tabs ul{margin:0px;padding:0px;display:grid;grid-auto-flow:column;grid-auto-columns:max-content;gap:5px;list-style:none}.tabs ul li{white-space:nowrap;color:var(--color-text-tab);border-top-left-radius:5px;border-top-right-radius:5px;background-color:var(--color-background-tab)}.tabs ul li:not(.active):hover{background-color:var(--color-background-tab-hover)}.tabs ul li.active,.tabs ul li :target{background-color:var(--color-background)}.tabs ul li.active a,.tabs ul li :target a{color:var(--color-text-tab-active);font-weight:bold}.tabs ul li a{display:block;padding:.7em;color:var(--color-text-tab)}body,.pure-table,.pure-table thead,.pure-table td,.pure-table th,.pure-form input,.pure-form textarea,.pure-form select,.edit-form .inner,.pure-menu-horizontal,footer,.sticky-tab,#diff-jump,.button-tag,#new-watch-form,#new-watch-form input:not(.pure-button),code,.messages li,#checkbox-operations,.inline-warning,a,.watch-controls img{transition:color .4s ease,background-color .4s ease,background .4s ease,border-color .4s ease,box-shadow .4s ease}body{color:var(--color-text);background:var(--color-background-page);font-family:Helvetica Neue,Helvetica,Lucida Grande,Arial,Ubuntu,Cantarell,Fira Sans,sans-serif}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}.status-icon{display:inline-block;height:1rem;vertical-align:middle}.pure-table-even{background:var(--color-background)}a{text-decoration:none;color:var(--color-link)}a.github-link{color:var(--color-icon-github);margin:0 1rem 0 .5rem}a.github-link svg{fill:currentColor}a.github-link:hover{color:var(--color-icon-github-hover)}#search-result-info{color:#fff}button.toggle-button{vertical-align:middle;background:rgba(0,0,0,0);border:none;cursor:pointer;color:var(--color-icon-github)}button.toggle-button:hover{color:var(--color-icon-github-hover)}button.toggle-button svg{fill:currentColor}button.toggle-button .icon-light{display:block}.pure-menu-horizontal{background:var(--color-background);padding:5px;display:flex;justify-content:space-between;align-items:center}#pure-menu-horizontal-spinner{height:3px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;width:100%;animation:gradient 200s ease infinite}body.spinner-active #pure-menu-horizontal-spinner{animation:gradient 1s ease infinite}@keyframes gradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}.pure-menu-heading{color:var(--color-text-menu-heading)}.pure-menu-link{color:var(--color-text-menu-link)}.pure-menu-link:hover{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}.tab-pane-inner{scroll-margin-top:200px}section.content{padding-bottom:1em;flex-direction:column;display:flex;align-items:center;justify-content:center}@media only screen and (max-width: 980px){section.content{padding-top:80px}}@media only screen and (min-width: 980px){section.content{padding-top:100px}}code{background:var(--color-background-code);color:var(--color-text)}.inline-tag,.restock-label,.tracking-ldjson-price-data,.watch-tag-list,.processor-badge{white-space:nowrap;border-radius:5px;padding:2px 5px;margin-right:4px;line-height:1.2rem}.processor-badge{font-weight:900}.watch-tag-list{color:var(--color-white);background:var(--color-text-watch-tag-list);text-decoration:none}.watch-tag-list:hover{text-decoration:none;opacity:.8;cursor:pointer}.watch-tag-list:visited{color:var(--color-white)}@media(min-width: 768px){.box{margin:0 1em !important}}.box{max-width:100%;margin:0 .3em;flex-direction:column;display:flex;justify-content:center}body:after{content:\"\";background:linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%)}body:after,body:before{display:block;height:650px;position:absolute;top:0;left:0;width:100%;z-index:-1}body::after{opacity:.91}body::before{content:\"\"}body:after,body:before{-webkit-clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%);clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%)}.button-small{font-size:85%}.button-xsmall{font-size:70%}.fetch-error{padding-top:1em;font-size:80%;max-width:400px;display:block}.pure-button-primary,a.pure-button-primary,.pure-button-selected,a.pure-button-selected{background-color:var(--color-background-button-primary)}.button-secondary{color:var(--color-text-button);border-radius:4px;text-shadow:0 1px 1px rgba(0,0,0,.2)}.button-success{background:var(--color-background-button-success)}.button-tag{background:var(--color-background-button-tag);color:var(--color-text-button);font-size:65%;border-bottom-left-radius:initial;border-bottom-right-radius:initial;margin-right:4px}.button-tag.active{background:var(--color-background-button-tag-active);font-weight:bold}.button-error{background:var(--color-background-button-error);color:var(--color-text-button-error)}.button-warning{background:var(--color-background-button-warning);color:var(--color-text-button-warning)}.button-secondary{background:var(--color-background-button-secondary)}.button-cancel{background:var(--color-background-button-cancel)}.messages li{list-style:none;padding:1em;border-radius:10px;color:var(--color-text-messages);font-weight:bold}.messages li.message{background:var(--color-background-messages-message)}.messages li.error{background:var(--color-background-messages-error)}.messages li.notice{background:var(--color-background-messages-notice)}.messages.with-share-link>*:hover{cursor:pointer}.notifications-wrapper{padding-top:.5rem}.notifications-wrapper #notification-test-log{margin-top:1rem;padding:1rem;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-width:100%;box-sizing:border-box;max-height:12rem;overflow-y:scroll;border:1px solid var(--color-border-notification);border-radius:5px}label:hover{cursor:pointer}.grey-form-border{border:1px solid var(--color-border-notification);padding:.5rem;border-radius:5px}#notification-error-log{border:1px solid var(--color-border-notification);padding:1rem;border-radius:5px;overflow-wrap:break-word}#token-table.pure-table td,#token-table.pure-table th{font-size:80%}.pure-form input[type=text].transparent-field{background-color:var(--color-background-new-watch-input-transparent) !important;color:var(--color-white) !important;border:1px solid hsla(0,0%,100%,.2) !important;box-shadow:none !important;-webkit-box-shadow:none !important}.pure-form input[type=text].transparent-field::placeholder{opacity:.5;color:hsla(0,0%,100%,.7);font-weight:lighter}#new-watch-form{background:var(--color-background-new-watch-form);padding:1em;border-radius:10px;margin-bottom:1em;max-width:100%}#new-watch-form #url::placeholder{font-weight:bold}#new-watch-form input{display:inline-block;margin-bottom:5px}#new-watch-form input:not(.pure-button){background-color:var(--color-background-new-watch-input);color:var(--color-text-new-watch-input)}#new-watch-form .label{display:none}#new-watch-form legend{color:var(--color-text-legend);font-weight:bold}@media only screen and (min-width: 760px){#new-watch-form #watch-add-wrapper-zone{display:flex;gap:.3rem;flex-direction:row;min-width:70vw}}#new-watch-form #watch-add-wrapper-zone>span{flex-grow:0}#new-watch-form #watch-add-wrapper-zone>span input{width:100%;padding-right:1em}#new-watch-form #watch-add-wrapper-zone>span:first-child{flex-grow:1}@media only screen and (max-width: 760px){#new-watch-form #watch-add-wrapper-zone #url{width:100%}}#new-watch-form #watch-group-tag{font-size:.9rem;padding:.3rem;display:flex;align-items:center;gap:.5rem;color:var(--color-white)}#new-watch-form #watch-group-tag label,#new-watch-form #watch-group-tag input{margin:0}#new-watch-form #watch-group-tag input{flex:1}#diff-col{padding-left:40px}#diff-jump{position:fixed;left:0px;top:120px;background:var(--color-background);padding:10px;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}#diff-jump a{color:var(--color-link);cursor:pointer;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-o-user-select:none}footer{padding:10px;background:var(--color-background);color:var(--color-text-footer);text-align:center}#feed-icon{vertical-align:middle}.sticky-tab{position:absolute;top:60px;font-size:65%;background:var(--color-background);padding:10px}@media only screen and (max-width: 980px){.sticky-tab{display:none}}.sticky-tab#left-sticky{left:0;position:fixed;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}.sticky-tab#right-sticky{right:0px}.sticky-tab#hosted-sticky{right:0px;top:100px;font-weight:bold}#new-version-text a{color:var(--color-link-new-version)}.watch-controls{color:#f8321b}.watch-controls .state-on img{opacity:.8}.watch-controls img{opacity:.2}.watch-controls img:hover{transition:opacity .3s;opacity:.8}.monospaced-textarea textarea{width:100%;font-family:monospace;white-space:pre;overflow-wrap:normal;overflow-x:auto}.pure-form fieldset{padding-top:0px}.pure-form fieldset ul{padding-bottom:0px;margin-bottom:0px}.pure-form .pure-control-group,.pure-form .pure-group,.pure-form .pure-controls{padding-bottom:1em}.pure-form .pure-control-group div,.pure-form .pure-group div,.pure-form .pure-controls div{margin:0px}.pure-form .pure-control-group .checkbox>*,.pure-form .pure-group .checkbox>*,.pure-form .pure-controls .checkbox>*{display:inline;vertical-align:middle}.pure-form .pure-control-group .checkbox>label,.pure-form .pure-group .checkbox>label,.pure-form .pure-controls .checkbox>label{padding-left:5px}.pure-form .pure-control-group legend,.pure-form .pure-group legend,.pure-form .pure-controls legend{color:var(--color-text-legend)}.pure-form .error input{background-color:var(--color-error-input)}.pure-form ul.errors{padding:.5em .6em;border:1px solid var(--color-error-list);border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form ul.errors li{margin-left:1em;color:var(--color-error-list)}.pure-form label{font-weight:bold}.pure-form textarea{width:100%}.pure-form .inline-radio ul{margin:0px;list-style:none}.pure-form .inline-radio ul li{display:flex;align-items:center;gap:1em}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 980px){.edit-form{padding:.5em;margin:0}#nav-menu{overflow-x:scroll}}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 980px){input[type=text]{width:100%}}.pure-table{border-color:var(--color-border-table-cell)}.pure-table thead{background-color:var(--color-background-table-thead);color:var(--color-text);border-bottom:1px solid var(--color-background-table-thead)}.pure-table td,.pure-table th{border-left-color:var(--color-border-table-cell)}.pure-table-striped tr:nth-child(2n-1) td{background-color:var(--color-table-stripe)}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{border:var(--color-border-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);background-color:var(--color-background-input);color:var(--color-text-input)}.pure-form input[type=color]:active,.pure-form input[type=date]:active,.pure-form input[type=datetime-local]:active,.pure-form input[type=datetime]:active,.pure-form input[type=email]:active,.pure-form input[type=month]:active,.pure-form input[type=number]:active,.pure-form input[type=password]:active,.pure-form input[type=search]:active,.pure-form input[type=tel]:active,.pure-form input[type=text]:active,.pure-form input[type=time]:active,.pure-form input[type=url]:active,.pure-form input[type=week]:active,.pure-form select:active,.pure-form textarea:active{background-color:var(--color-background-input)}input::placeholder,textarea::placeholder{color:var(--color-text-input-placeholder)}.m-d{min-width:100%}@media only screen and (min-width: 761px){.m-d{min-width:80%}}.pure-form-stacked>div:first-child{display:block}.tab-pane-inner{padding:0px}.tab-pane-inner:not(:target){display:none}.tab-pane-inner:target{display:block}.beta-logo{height:50px;right:-3px;top:-3px;position:absolute}#selector-header{padding-bottom:1em}body.full-width .edit-form{width:95%}.edit-form{min-width:70%;max-width:95%}.edit-form .box-wrap{position:relative}.edit-form .inner{background:var(--color-background);padding:20px}.edit-form #actions{display:block;background:var(--color-background)}.edit-form #actions .pure-control-group{display:flex;gap:.625em;flex-wrap:wrap}.edit-form .pure-form-message-inline{padding-left:0;color:var(--color-text-input-description)}.edit-form .pure-form-message-inline code{font-size:.875em}.border-fieldset{border:1px solid #ccc;padding:1rem;border-radius:5px;margin-bottom:1rem}.border-fieldset h3{margin-top:0}.border-fieldset fieldset:last-of-type{padding-bottom:0}.border-fieldset fieldset:last-of-type .pure-control-group{padding-bottom:0}ul{padding-left:1em;padding-top:0px;margin-top:4px}.time-check-widget tr{display:inline}.time-check-widget tr input[type=number]{width:5em}@media only screen and (max-width: 760px){.time-check-widget tbody{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:.625em .3125em;align-items:center}.time-check-widget tr{display:contents}.time-check-widget tr th{text-align:right;padding-right:5px}.time-check-widget tr input[type=number]{width:100%;max-width:5em}}#webdriver_delay{width:5em}#api-key:hover{cursor:pointer}#api-key-copy{color:var(--color-api-key)}.button-green{background-color:var(--color-background-button-green)}.button-red{background-color:var(--color-background-button-red)}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#checkbox-operations{background:var(--color-background-checkbox-operations);padding:1em;border-radius:10px;margin-bottom:1em;display:none}#checkbox-operations button{margin-bottom:3px;margin-top:3px;display:inline-flex;align-items:center}.checkbox-uuid>*{vertical-align:middle}.inline-warning{border:1px solid var(--color-border-warning);padding:.5rem;border-radius:5px;color:var(--color-warning)}.inline-warning>span{display:inline-block;vertical-align:middle}.inline-warning img.inline-warning-icon{display:inline;height:26px;vertical-align:middle}.tracking-ldjson-price-data{background-color:var(--color-background-button-green);color:#000;opacity:.6}.ldjson-price-track-offer{font-weight:bold;font-style:italic}.ldjson-price-track-offer a.pure-button{border-radius:3px;padding:3px;background-color:var(--color-background-button-green)}.price-follow-tag-icon{display:inline-block;height:.8rem;vertical-align:middle}#quick-watch-processor-type ul#processor{color:#fff;padding-left:0px}#quick-watch-processor-type ul#processor li{list-style:none;font-size:.9rem;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.5rem;margin-bottom:.5rem}#quick-watch-processor-type label,#quick-watch-processor-type input{padding:0;margin:0}.restock-label.in-stock{background-color:var(--color-background-button-green);color:#fff}.restock-label.not-in-stock{background-color:var(--color-background-button-cancel);color:#777}.restock-label.error{background-color:var(--color-background-button-error);color:#fff;opacity:.7}.restock-label svg{vertical-align:middle}#chrome-extension-link{padding:9px;border:1px solid var(--color-grey-800);border-radius:10px;vertical-align:middle}#chrome-extension-link img{height:21px;padding:2px;vertical-align:middle}#realtime-conn-error{position:fixed;bottom:0;left:0;background:var(--color-warning);padding:10px;font-size:.8rem;color:#fff;opacity:.8}#bottom-horizontal-offscreen{position:fixed;bottom:0;left:0;right:0;width:100%;min-height:50px;max-height:50vh;background:hsla(0,0%,100%,.7215686275);border-top:1px solid var(--color-border-table-cell);padding:10px;box-shadow:0 -2px 10px rgba(0,0,0,.2);z-index:100;overflow-y:auto;transition:opacity .3s ease-in-out;scroll-margin-bottom:10px;display:flex;justify-content:center;align-items:center}ul#highlightSnippetActions{list-style:none}ul#highlightSnippetActions li{display:inline-block}\n"
  },
  {
    "path": "changedetectionio/store/__init__.py",
    "content": "import shutil\n\nfrom changedetectionio.strtobool import strtobool\n\nfrom changedetectionio.validate_url import is_safe_valid_url\n\nfrom flask import (\n    flash\n)\nfrom flask_babel import gettext\n\nfrom ..model import App, Watch\nfrom copy import deepcopy\nfrom os import path, unlink\nimport json\nimport os\nimport re\nimport secrets\nimport sys\nimport time\nimport uuid as uuid_builder\nfrom loguru import logger\nfrom blinker import signal\n\nfrom ..model.Tags import TagsDict\n\n# Try to import orjson for faster JSON serialization\ntry:\n    import orjson\n\n    HAS_ORJSON = True\nexcept ImportError:\n    HAS_ORJSON = False\n\nfrom ..processors import get_custom_watch_obj_for_processor\n\n# Import the base class and helpers\nfrom .file_saving_datastore import FileSavingDataStore, load_all_watches, load_all_tags, save_json_atomic\nfrom .updates import DatastoreUpdatesMixin\n\n# Because the server will run as a daemon and wont know the URL for notification links when firing off a notification\nBASE_URL_NOT_SET_TEXT = '(\"Base URL\" not set - see settings - notifications)'\n\ndictfilt = lambda x, y: dict([(i, x[i]) for i in x if i in set(y)])\n\n\n# Is there an existing library to ensure some data store (JSON etc) is in sync with CRUD methods?\n# Open a github issue if you know something :)\n# https://stackoverflow.com/questions/6190468/how-to-trigger-function-on-value-change\nclass ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):\n    __version_check = True\n\n    def __init__(self, datastore_path=\"/datastore\", include_default_watches=True, version_tag=\"0.0.0\"):\n        # Initialize parent class\n        super().__init__()\n\n        # Should only be active for docker\n        # logging.basicConfig(filename='/dev/stdout', level=logging.INFO)\n        self.datastore_path = datastore_path\n        self.start_time = time.time()\n        self.save_version_copy_json_db(version_tag)\n        self.reload_state(datastore_path=datastore_path, include_default_watches=include_default_watches, version_tag=version_tag)\n\n    def save_version_copy_json_db(self, version_tag):\n        \"\"\"\n        Create version-tagged backup of changedetection.json.\n\n        This is called on version upgrades to preserve a backup in case\n        the new version has issues.\n        \"\"\"\n        import re\n\n        version_text = re.sub(r'\\D+', '-', version_tag)\n        db_path = os.path.join(self.datastore_path, \"changedetection.json\")\n        db_path_version_backup = os.path.join(self.datastore_path, f\"changedetection-{version_text}.json\")\n\n        if not os.path.isfile(db_path_version_backup) and os.path.isfile(db_path):\n            from shutil import copyfile\n            logger.info(f\"Backing up changedetection.json due to new version to '{db_path_version_backup}'.\")\n            copyfile(db_path, db_path_version_backup)\n\n    def _load_settings(self, filename=\"changedetection.json\"):\n        \"\"\"\n        Load settings from storage.\n\n        File backend implementation: reads from changedetection.json\n\n        Returns:\n            dict: Settings data loaded from storage\n        \"\"\"\n        changedetection_json = os.path.join(self.datastore_path, filename)\n\n        logger.info(f\"Loading settings from {changedetection_json}\")\n\n        if HAS_ORJSON:\n            with open(changedetection_json, 'rb') as f:\n                return orjson.loads(f.read())\n        else:\n            with open(changedetection_json, 'r', encoding='utf-8') as f:\n                return json.load(f)\n\n    def _apply_settings(self, settings_data):\n        \"\"\"\n        Apply loaded settings data to internal data structure.\n\n        Args:\n            settings_data: Dictionary loaded from changedetection.json\n        \"\"\"\n        # Apply top-level fields\n        if 'app_guid' in settings_data:\n            self.__data['app_guid'] = settings_data['app_guid']\n        if 'build_sha' in settings_data:\n            self.__data['build_sha'] = settings_data['build_sha']\n        if 'version_tag' in settings_data:\n            self.__data['version_tag'] = settings_data['version_tag']\n\n        # Apply settings sections\n        if 'settings' in settings_data:\n            if 'headers' in settings_data['settings']:\n                self.__data['settings']['headers'].update(settings_data['settings']['headers'])\n            if 'requests' in settings_data['settings']:\n                self.__data['settings']['requests'].update(settings_data['settings']['requests'])\n            if 'application' in settings_data['settings']:\n                self.__data['settings']['application'].update(settings_data['settings']['application'])\n\n                # Use our Tags dict with cleanup helpers etc\n                # @todo Same for Watches\n                existing_tags = settings_data.get('settings', {}).get('application', {}).get('tags') or {}\n                self.__data['settings']['application']['tags'] = TagsDict(existing_tags, datastore_path=self.datastore_path)\n\n        # More or less for the old format which had this data in the one url-watches.json\n        # cant hurt to leave it here,\n        if 'watching' in settings_data:\n            self.__data['watching'].update(settings_data['watching'])\n\n    def _rehydrate_tags(self):\n        \"\"\"Rehydrate tag entities from stored data into Tag objects with restock_diff processor.\"\"\"\n        from ..model import Tag\n\n        for uuid, tag in self.__data['settings']['application']['tags'].items():\n            # Force processor to restock_diff for override functionality (technical debt)\n            tag['processor'] = 'restock_diff'\n\n            self.__data['settings']['application']['tags'][uuid] = Tag.model(\n                datastore_path=self.datastore_path,\n                __datastore=self.__data,\n                default=tag\n            )\n            logger.info(f\"Tag: {uuid} {tag['title']}\")\n\n    def _rehydrate_watches(self):\n        \"\"\"Rehydrate watch entities from stored data (converts dicts to Watch objects).\"\"\"\n        watch_count = len(self.__data.get('watching', {}))\n        if watch_count == 0:\n            return\n\n        logger.info(f\"Rehydrating {watch_count} watches...\")\n        watching_rehydrated = {}\n        for uuid, watch_dict in self.__data.get('watching', {}).items():\n            if isinstance(watch_dict, dict):\n                watching_rehydrated[uuid] = self.rehydrate_entity(uuid, watch_dict)\n            else:\n                logger.error(f\"Watch UUID {uuid} already rehydrated\")\n\n        self.__data['watching'] = watching_rehydrated\n        logger.success(f\"Rehydrated {watch_count} watches into Watch objects\")\n\n\n    def _load_state(self, main_settings_filename=\"changedetection.json\"):\n        \"\"\"\n        Load complete datastore state from storage.\n\n        Orchestrates loading of settings, watches, and tags using polymorphic methods.\n        \"\"\"\n        # Load settings\n        settings_data = self._load_settings(filename=main_settings_filename)\n        self._apply_settings(settings_data)\n\n        # Load watches, scan them from the disk\n        self._load_watches()\n        self._rehydrate_watches()\n\n        # Load tags from individual tag.json files\n        # These will override any tags in settings (migration path)\n        self._load_tags()\n\n        # Rehydrate any remaining tags from settings (legacy/fallback)\n        self._rehydrate_tags()\n\n    def reload_state(self, datastore_path, include_default_watches, version_tag):\n        \"\"\"\n        Load datastore from storage or create new one.\n\n        Supports two scenarios:\n        1. NEW format: changedetection.json exists → load and run updates if needed\n        2. EMPTY: No changedetection.json → create new OR trigger migration from legacy\n\n        Note: Legacy url-watches.json migration happens in update_26, not here.\n        \"\"\"\n        logger.info(f\"Datastore path is '{datastore_path}'\")\n\n        # CRITICAL: Update datastore_path (was using old path from __init__)\n        self.datastore_path = datastore_path\n\n        # Initialize data structure\n        self.__data = App.model(datastore_path=datastore_path)\n        self.json_store_path = os.path.join(self.datastore_path, \"changedetection.json\")\n\n        # Base definition for all watchers (deepcopy part of #569)\n        self.generic_definition = deepcopy(Watch.model(datastore_path=datastore_path, __datastore=self.__data, default={}))\n\n        # Load build SHA if available (Docker deployments)\n        if path.isfile('changedetectionio/source.txt'):\n            with open('changedetectionio/source.txt') as f:\n                self.__data['build_sha'] = f.read()\n\n        # Check if datastore already exists\n        changedetection_json = os.path.join(self.datastore_path, \"changedetection.json\")\n        changedetection_json_old_schema = os.path.join(self.datastore_path, \"url-watches.json\")\n\n        if os.path.exists(changedetection_json):\n            # Run schema updates if needed\n            # Pass current schema version from loaded datastore (defaults to 0 if not set)\n            # Load existing datastore (changedetection.json + watch.json files)\n            logger.info(\"Loading existing datastore\")\n            self._load_state()\n            current_schema = self.data['settings']['application'].get('schema_version', 0)\n            self.run_updates(current_schema_version=current_schema)\n\n        # Legacy datastore detected - trigger migration, even works if the schema is much before the migration step.\n        elif os.path.exists(changedetection_json_old_schema):\n\n            logger.critical(f\"Legacy datastore detected at {changedetection_json_old_schema}, loading and running updates\")\n            self._load_state(main_settings_filename=\"url-watches.json\")\n            # update 26 will load the whole old config from disk to __data\n            current_schema = self.__data['settings']['application'].get('schema_version', 0)\n            self.run_updates(current_schema_version=current_schema)\n            # Probably tags were also shifted to disk and many other changes, so best to reload here.\n            self._load_state()\n\n        else:\n            # No datastore yet - check if this is a fresh install or legacy migration\n            self.init_fresh_install(include_default_watches=include_default_watches,\n                                    version_tag=version_tag)\n            # Maybe they copied a bunch of watch subdirs across too\n            self._load_state()\n\n    def init_fresh_install(self, include_default_watches, version_tag):\n      # Generate app_guid FIRST (required for all operations)\n        if \"pytest\" in sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ:\n            self.__data['app_guid'] = \"test-\" + str(uuid_builder.uuid4())\n        else:\n            self.__data['app_guid'] = str(uuid_builder.uuid4())\n\n        # Generate RSS access token\n        self.__data['settings']['application']['rss_access_token'] = secrets.token_hex(16)\n\n        # Generate API access token\n        self.__data['settings']['application']['api_access_token'] = secrets.token_hex(16)\n        logger.warning(f\"No datastore found, creating new datastore at {self.datastore_path}\")\n\n        # Set schema version to latest (no updates needed)\n        latest_update_available = self.get_updates_available().pop()\n        logger.info(f\"Marking fresh install to schema version {latest_update_available}\")\n        self.__data['settings']['application']['schema_version'] = latest_update_available\n\n        # Add default watches if requested\n        if include_default_watches:\n            self.add_watch(\n                url='https://news.ycombinator.com/',\n                tag='Tech news',\n                extras={'fetch_backend': 'html_requests'}\n            )\n            self.add_watch(\n                url='https://changedetection.io/CHANGELOG.txt',\n                tag='changedetection.io',\n                extras={'fetch_backend': 'html_requests'}\n            )\n\n        # Create changedetection.json immediately\n        try:\n            self._save_settings()\n            logger.info(\"Created changedetection.json for new datastore\")\n        except Exception as e:\n            logger.error(f\"Failed to create initial changedetection.json: {e}\")\n\n\n\n        # Set version tag\n        self.__data['version_tag'] = version_tag\n\n        # Validate proxies.json if it exists\n        _ = self.proxy_list  # Just to test parsing\n\n        # Ensure app_guid exists (for datastores loaded from existing files)\n        if 'app_guid' not in self.__data:\n            if \"pytest\" in sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ:\n                self.__data['app_guid'] = \"test-\" + str(uuid_builder.uuid4())\n            else:\n                self.__data['app_guid'] = str(uuid_builder.uuid4())\n            self.commit()\n\n        # Ensure RSS access token exists\n        if not self.__data['settings']['application'].get('rss_access_token'):\n            secret = secrets.token_hex(16)\n            self.__data['settings']['application']['rss_access_token'] = secret\n            self.commit()\n\n        # Ensure API access token exists\n        if not self.__data['settings']['application'].get('api_access_token'):\n            secret = secrets.token_hex(16)\n            self.__data['settings']['application']['api_access_token'] = secret\n            self.commit()\n\n        # Handle password reset lockfile\n        password_reset_lockfile = os.path.join(self.datastore_path, \"removepassword.lock\")\n        if path.isfile(password_reset_lockfile):\n            self.remove_password()\n            unlink(password_reset_lockfile)\n\n    def rehydrate_entity(self, uuid, entity, processor_override=None):\n        \"\"\"Set the dict back to the dict Watch object\"\"\"\n        entity['uuid'] = uuid\n\n        if processor_override:\n            watch_class = get_custom_watch_obj_for_processor(processor_override)\n            entity['processor'] = processor_override\n        else:\n            watch_class = get_custom_watch_obj_for_processor(entity.get('processor'))\n\n        if entity.get('processor') != 'text_json_diff':\n            logger.trace(f\"Loading Watch object '{watch_class.__module__}.{watch_class.__name__}' for UUID {uuid}\")\n\n        entity = watch_class(datastore_path=self.datastore_path, __datastore=self.__data, default=entity)\n        return entity\n\n    # ============================================================================\n    # FileSavingDataStore Abstract Method Implementations\n    # ============================================================================\n\n    def _watch_exists(self, uuid):\n        \"\"\"Check if watch exists in datastore.\"\"\"\n        return uuid in self.__data['watching']\n\n    def _get_watch_dict(self, uuid):\n        \"\"\"Get watch as dictionary.\"\"\"\n        return dict(self.__data['watching'][uuid])\n\n    def _build_settings_data(self):\n        \"\"\"\n        Build settings data structure for saving.\n\n        Tags behavior depends on schema version:\n        - Before update_28 (schema < 28): Tags saved in settings for migration\n        - After update_28 (schema >= 28): Tags excluded from settings (in individual files)\n\n        Returns:\n            dict: Settings data ready for serialization\n        \"\"\"\n        import copy\n\n        # Deep copy settings to avoid modifying the original\n        settings_copy = copy.deepcopy(self.__data['settings'])\n\n        # Is saved as {uuid}/tag.json\n        settings_copy['application']['tags'] = {}\n\n        return {\n            'note': 'Settings file - watches are in {uuid}/watch.json, tags are in {uuid}/tag.json',\n            'app_guid': self.__data.get('app_guid'),\n            'settings': settings_copy,\n            'build_sha': self.__data.get('build_sha'),\n            'version_tag': self.__data.get('version_tag')\n        }\n\n    def _save_settings(self):\n        \"\"\"\n        Save settings to storage.\n\n        File backend implementation: saves to changedetection.json\n        Implementation of abstract method from FileSavingDataStore.\n        Uses the generic save_json_atomic helper.\n\n        Raises:\n            OSError: If disk is full or other I/O error\n        \"\"\"\n        settings_data = self._build_settings_data()\n        changedetection_json = os.path.join(self.datastore_path, \"changedetection.json\")\n        save_json_atomic(changedetection_json, settings_data, label=\"settings\")\n\n    def _load_watches(self):\n        \"\"\"\n        Load all watches from storage.\n\n        File backend implementation: reads individual watch.json files\n        Implementation of abstract method from FileSavingDataStore.\n        Delegates to helper function and stores results in internal data structure.\n        \"\"\"\n\n        # Store loaded data\n        # @note this will also work for the old legacy format because self.__data['watching'] should already have them loaded by this point.\n        self.__data['watching'].update(load_all_watches(\n            self.datastore_path,\n            self.rehydrate_entity\n        ))\n        logger.debug(f\"Loaded {len(self.__data['watching'])} watches\")\n\n    def _load_tags(self):\n        \"\"\"\n        Load all tags from storage.\n\n        File backend implementation: reads individual tag.json files.\n        Tags loaded from files override any tags in settings (migration path).\n        \"\"\"\n        from ..model import Tag\n\n        def rehydrate_tag(uuid, entity_dict):\n            \"\"\"Rehydrate tag as Tag object with forced restock_diff processor.\"\"\"\n            entity_dict['uuid'] = uuid\n            entity_dict['processor'] = 'restock_diff'  # Force processor for override functionality\n\n            return Tag.model(\n                datastore_path=self.datastore_path,\n                __datastore=self.__data,\n                default=entity_dict\n            )\n\n        tags = load_all_tags(\n            self.datastore_path,\n            rehydrate_tag\n        )\n\n        # Override settings tags with loaded tags\n        # This ensures tag.json files take precedence over settings\n        if tags:\n            self.__data['settings']['application']['tags'].update(tags)\n            logger.info(f\"Loaded {len(tags)} tags from individual tag.json files\")\n\n    def _delete_watch(self, uuid):\n        \"\"\"\n        Delete a watch from storage.\n\n        File backend implementation: deletes entire {uuid}/ directory recursively.\n        Implementation of abstract method from FileSavingDataStore.\n\n        Args:\n            uuid: Watch UUID to delete\n        \"\"\"\n        watch_dir = os.path.join(self.datastore_path, uuid)\n        if os.path.exists(watch_dir):\n            shutil.rmtree(watch_dir)\n            logger.info(f\"Deleted watch directory: {watch_dir}\")\n\n    # ============================================================================\n    # Watch Management Methods\n    # ============================================================================\n\n    def set_last_viewed(self, uuid, timestamp):\n        logger.debug(f\"Setting watch UUID: {uuid} last viewed to {int(timestamp)}\")\n        self.data['watching'][uuid].update({'last_viewed': int(timestamp)})\n        self.data['watching'][uuid].commit()\n\n        watch_check_update = signal('watch_check_update')\n        if watch_check_update:\n            watch_check_update.send(watch_uuid=uuid)\n\n    def remove_password(self):\n        self.__data['settings']['application']['password'] = False\n        self.commit()\n\n    def clear_all_last_checksums(self):\n        \"\"\"\n        Delete all last-checksum.txt files to force reprocessing of all watches.\n\n        This should be called when global settings change, since watches inherit\n        configuration and need to reprocess even if their individual watch dict\n        hasn't been modified.\n\n        Note: We delete the checksum file rather than setting was_edited=True because:\n        - was_edited is not persisted across restarts\n        - File deletion ensures reprocessing works across app restarts\n        \"\"\"\n        deleted_count = 0\n        for uuid in self.__data['watching'].keys():\n            watch = self.__data['watching'][uuid]\n            if watch.data_dir:\n                checksum_file = os.path.join(watch.data_dir, 'last-checksum.txt')\n                if os.path.isfile(checksum_file):\n                    try:\n                        os.remove(checksum_file)\n                        deleted_count += 1\n                        logger.debug(f\"Cleared checksum for watch {uuid}\")\n                    except OSError as e:\n                        logger.warning(f\"Failed to delete checksum file for {uuid}: {e}\")\n\n        logger.info(f\"Cleared {deleted_count} checksum files to force reprocessing\")\n        return deleted_count\n\n    def clear_checksums_for_tag(self, tag_uuid):\n        \"\"\"\n        Delete last-checksum.txt files for all watches using a specific tag.\n\n        This should be called when a tag configuration is edited, since watches\n        inherit tag settings and need to reprocess.\n\n        Args:\n            tag_uuid: UUID of the tag that was modified\n\n        Returns:\n            int: Number of checksum files deleted\n        \"\"\"\n        deleted_count = 0\n        for uuid, watch in self.__data['watching'].items():\n            if watch.get('tags') and tag_uuid in watch['tags']:\n                if watch.data_dir:\n                    checksum_file = os.path.join(watch.data_dir, 'last-checksum.txt')\n                    if os.path.isfile(checksum_file):\n                        try:\n                            os.remove(checksum_file)\n                            deleted_count += 1\n                            logger.debug(f\"Cleared checksum for watch {uuid} (tag {tag_uuid})\")\n                        except OSError as e:\n                            logger.warning(f\"Failed to delete checksum file for {uuid}: {e}\")\n\n        logger.info(f\"Cleared {deleted_count} checksum files for tag {tag_uuid}\")\n        return deleted_count\n\n    def commit(self):\n        \"\"\"\n        Save settings immediately to disk using atomic write.\n\n        Uses atomic write pattern (temp file + rename) for crash safety.\n\n        Fire-and-forget: Logs errors but does not raise exceptions.\n        Settings data remains in memory even if save fails, so next commit will retry.\n        \"\"\"\n        try:\n            self._save_settings()\n            logger.debug(\"Committed settings\")\n        except Exception as e:\n            logger.error(f\"Failed to commit settings: {e}\")\n\n    def update_watch(self, uuid, update_obj):\n\n        # It's possible that the watch could be deleted before update\n        if not self.__data['watching'].get(uuid):\n            return\n\n        with self.lock:\n\n            # In python 3.9 we have the |= dict operator, but that still will lose data on nested structures...\n            for dict_key, d in self.generic_definition.items():\n                if isinstance(d, dict):\n                    if update_obj is not None and dict_key in update_obj:\n                        self.__data['watching'][uuid][dict_key].update(update_obj[dict_key])\n                        del (update_obj[dict_key])\n\n            self.__data['watching'][uuid].update(update_obj)\n\n        # Immediate save\n        self.__data['watching'][uuid].commit()\n\n    @property\n    def threshold_seconds(self):\n        seconds = 0\n        for m, n in Watch.mtable.items():\n            x = self.__data['settings']['requests']['time_between_check'].get(m)\n            if x:\n                seconds += x * n\n        return seconds\n\n    @property\n    def unread_changes_count(self):\n        unread_changes_count = 0\n        for uuid, watch in self.__data['watching'].items():\n            if watch.history_n >= 2 and watch.viewed == False:\n                unread_changes_count += 1\n\n        return unread_changes_count\n\n    @property\n    def data(self):\n        # Re #152, Return env base_url if not overriden\n        # Re #148 - Some people have just {{ base_url }} in the body or title, but this may break some notification services\n        #           like 'Join', so it's always best to atleast set something obvious so that they are not broken.\n\n        active_base_url = BASE_URL_NOT_SET_TEXT\n        if self.__data['settings']['application'].get('base_url'):\n            active_base_url = self.__data['settings']['application'].get('base_url')\n        elif os.getenv('BASE_URL'):\n            active_base_url = os.getenv('BASE_URL')\n\n        # I looked at various ways todo the following, but in the end just copying the dict seemed simplest/most reliable\n        # even given the memory tradeoff - if you know a better way.. maybe return d|self.__data.. or something\n        d = self.__data\n        d['settings']['application']['active_base_url'] = active_base_url.strip('\" ')\n        return d\n\n    # Delete a single watch by UUID\n    def delete(self, uuid):\n        \"\"\"\n        Delete a watch by UUID.\n\n        Uses abstracted storage method for backend-agnostic deletion.\n        Supports 'all' to delete all watches (mainly for testing).\n\n        Args:\n            uuid: Watch UUID to delete, or 'all' to delete all watches\n        \"\"\"\n        with self.lock:\n            if uuid == 'all':\n                # Delete all watches - capture UUIDs first before modifying dict\n                all_uuids = list(self.__data['watching'].keys())\n\n                for watch_uuid in all_uuids:\n                    # Delete from storage using polymorphic method\n                    try:\n                        self._delete_watch(watch_uuid)\n                    except Exception as e:\n                        logger.error(f\"Failed to delete watch {watch_uuid} from storage: {e}\")\n\n                    # Send delete signal\n                    watch_delete_signal = signal('watch_deleted')\n                    if watch_delete_signal:\n                        watch_delete_signal.send(watch_uuid=watch_uuid)\n\n                # Clear the dict\n                self.__data['watching'] = {}\n\n                # Mainly used for testing to allow all items to flush before running next test\n                time.sleep(1)\n\n            else:\n                # Delete single watch from storage using polymorphic method\n                try:\n                    self._delete_watch(uuid)\n                except Exception as e:\n                    logger.error(f\"Failed to delete watch {uuid} from storage: {e}\")\n\n                # Remove from watching dict\n                del self.data['watching'][uuid]\n\n                # Send delete signal\n                watch_delete_signal = signal('watch_deleted')\n                if watch_delete_signal:\n                    watch_delete_signal.send(watch_uuid=uuid)\n\n    # Clone a watch by UUID\n    def clone(self, uuid):\n        url = self.data['watching'][uuid].get('url')\n        # No need to deepcopy here - add_watch() will deepcopy extras anyway (line 569)\n        # Just pass a dict copy (with lock for thread safety)\n        # NOTE: dict() is shallow copy but safe since add_watch() deepcopies it\n        with self.lock:\n            extras = dict(self.data['watching'][uuid])\n        new_uuid = self.add_watch(url=url, extras=extras)\n        watch = self.data['watching'][new_uuid]\n        return new_uuid\n\n    def url_exists(self, url):\n\n        # Probably their should be dict...\n        for watch in self.data['watching'].values():\n            if watch['url'].lower() == url.lower():\n                return True\n\n        return False\n\n    # Remove a watchs data but keep the entry (URL etc)\n    def clear_watch_history(self, uuid):\n        self.__data['watching'][uuid].clear_watch()\n        self.__data['watching'][uuid].commit()\n\n    def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_immediately=True):\n\n        if extras is None:\n            extras = {}\n\n        # Incase these are copied across, assume it's a reference and deepcopy()\n        apply_extras = deepcopy(extras)\n        apply_extras['tags'] = [] if not apply_extras.get('tags') else apply_extras.get('tags')\n\n        # Was it a share link? try to fetch the data\n        if (url.startswith(\"https://changedetection.io/share/\")):\n            import requests\n\n            try:\n                r = requests.request(method=\"GET\",\n                                     url=url,\n                                     # So we know to return the JSON instead of the human-friendly \"help\" page\n                                     headers={'App-Guid': self.__data['app_guid']},\n                                     timeout=5.0)  # 5 second timeout to prevent blocking\n                res = r.json()\n\n                # List of permissible attributes we accept from the wild internet\n                for k in [\n                    'body',\n                    'browser_steps',\n                    'css_filter',\n                    'extract_text',\n                    'headers',\n                    'ignore_text',\n                    'include_filters',\n                    'method',\n                    'paused',\n                    'previous_md5',\n                    'processor',\n                    'subtractive_selectors',\n                    'tag',\n                    'tags',\n                    'text_should_not_be_present',\n                    'title',\n                    'trigger_text',\n                    'url',\n                    'use_page_title_in_list',\n                    'webdriver_js_execute_code',\n                ]:\n                    if res.get(k):\n                        if k != 'css_filter':\n                            apply_extras[k] = res[k]\n                        else:\n                            # We renamed the field and made it a list\n                            apply_extras['include_filters'] = [res['css_filter']]\n\n            except Exception as e:\n                logger.error(f\"Error fetching metadata for shared watch link {url} {str(e)}\")\n                flash(gettext(\"Error fetching metadata for {}\").format(url), 'error')\n                return False\n\n        if not is_safe_valid_url(url):\n            from flask import has_request_context\n            if has_request_context():\n                flash(gettext('Watch protocol is not permitted or invalid URL format'), 'error')\n            else:\n                logger.error(f\"add_watch: URL '{url}' is not permitted or invalid, skipping.\")\n            return None\n\n        # Check PAGE_WATCH_LIMIT if set\n        page_watch_limit = os.getenv('PAGE_WATCH_LIMIT')\n        if page_watch_limit:\n            try:\n                page_watch_limit = int(page_watch_limit)\n                current_watch_count = len(self.__data['watching'])\n                if current_watch_count >= page_watch_limit:\n                    logger.error(f\"Watch limit reached: {current_watch_count}/{page_watch_limit} watches. Cannot add {url}\")\n                    flash(gettext(\"Watch limit reached ({}/{} watches). Cannot add more watches.\").format(current_watch_count, page_watch_limit), 'error')\n                    return None\n            except ValueError:\n                logger.warning(f\"Invalid PAGE_WATCH_LIMIT value: {page_watch_limit}, ignoring limit check\")\n\n        if tag and type(tag) == str:\n            # Then it's probably a string of the actual tag by name, split and add it\n            for t in tag.split(','):\n                # for each stripped tag, add tag as UUID\n                for a_t in t.split(','):\n                    tag_uuid = self.add_tag(a_t)\n                    apply_extras['tags'].append(tag_uuid)\n\n        # Or if UUIDs given directly\n        if tag_uuids:\n            for t in tag_uuids:\n                apply_extras['tags'] = list(set(apply_extras['tags'] + [t.strip()]))\n\n        # Make any uuids unique\n        if apply_extras.get('tags'):\n            apply_extras['tags'] = list(set(apply_extras.get('tags')))\n\n        # If the processor also has its own Watch implementation\n        watch_class = get_custom_watch_obj_for_processor(apply_extras.get('processor'))\n        new_watch = watch_class(datastore_path=self.datastore_path, __datastore=self.__data, url=url)\n\n        new_uuid = new_watch.get('uuid')\n\n        logger.debug(f\"Adding URL '{url}' - {new_uuid}\")\n\n        for k in ['uuid', 'history', 'last_checked', 'last_changed', 'newest_history_key', 'previous_md5', 'viewed']:\n            if k in apply_extras:\n                del apply_extras[k]\n\n        if not apply_extras.get('date_created'):\n            apply_extras['date_created'] = int(time.time())\n\n        new_watch.update(apply_extras)\n        new_watch.ensure_data_dir_exists()\n        self.__data['watching'][new_uuid] = new_watch\n\n        if save_immediately:\n            # Save immediately using commit\n            new_watch.commit()\n            logger.debug(f\"Saved new watch {new_uuid}\")\n\n        logger.debug(f\"Added '{url}'\")\n\n        return new_uuid\n\n    def _watch_resource_exists(self, watch_uuid, resource_name):\n        \"\"\"\n        Check if a watch-related resource exists.\n\n        File backend implementation: checks if file exists in watch directory.\n\n        Args:\n            watch_uuid: Watch UUID\n            resource_name: Name of resource (e.g., \"last-screenshot.png\")\n\n        Returns:\n            bool: True if resource exists\n        \"\"\"\n        resource_path = os.path.join(self.datastore_path, watch_uuid, resource_name)\n        return path.isfile(resource_path)\n\n    def visualselector_data_is_ready(self, watch_uuid):\n        \"\"\"\n        Check if visual selector data (screenshot + elements) is ready.\n\n        Returns:\n            bool: True if both screenshot and elements data exist\n        \"\"\"\n        has_screenshot = self._watch_resource_exists(watch_uuid, \"last-screenshot.png\")\n        has_elements = self._watch_resource_exists(watch_uuid, \"elements.deflate\")\n        return has_screenshot and has_elements\n\n    # Old sync_to_json and save_datastore methods removed - now handled by FileSavingDataStore parent class\n\n    @property\n    def proxy_list(self):\n        proxy_list = {}\n        proxy_list_file = os.path.join(self.datastore_path, 'proxies.json')\n\n        # Load from external config file\n        if path.isfile(proxy_list_file):\n            if HAS_ORJSON:\n                # orjson.loads() expects UTF-8 encoded bytes #3611\n                with open(os.path.join(self.datastore_path, \"proxies.json\"), 'rb') as f:\n                    proxy_list = orjson.loads(f.read())\n            else:\n                with open(os.path.join(self.datastore_path, \"proxies.json\"), encoding='utf-8') as f:\n                    proxy_list = json.load(f)\n\n        # Mapping from UI config if available\n        extras = self.data['settings']['requests'].get('extra_proxies')\n        if extras:\n            i = 0\n            for proxy in extras:\n                i += 0\n                if proxy.get('proxy_name') and proxy.get('proxy_url'):\n                    k = \"ui-\" + str(i) + proxy.get('proxy_name')\n                    proxy_list[k] = {'label': proxy.get('proxy_name'), 'url': proxy.get('proxy_url')}\n\n        if proxy_list and strtobool(os.getenv('ENABLE_NO_PROXY_OPTION', 'True')):\n            proxy_list[\"no-proxy\"] = {'label': \"No proxy\", 'url': ''}\n\n        return proxy_list if len(proxy_list) else None\n\n    def get_preferred_proxy_for_watch(self, uuid):\n        \"\"\"\n        Returns the preferred proxy by ID key\n        :param uuid: UUID\n        :return: proxy \"key\" id\n        \"\"\"\n\n        if self.proxy_list is None:\n            return None\n\n        # If it's a valid one\n        watch = self.data['watching'].get(uuid)\n\n        if strtobool(os.getenv('ENABLE_NO_PROXY_OPTION', 'True')) and watch.get('proxy') == \"no-proxy\":\n            return None\n\n        if watch.get('proxy') and watch.get('proxy') in list(self.proxy_list.keys()):\n            return watch.get('proxy')\n\n        # not valid (including None), try the system one\n        else:\n            system_proxy_id = self.data['settings']['requests'].get('proxy')\n            # Is not None and exists\n            if self.proxy_list.get(system_proxy_id):\n                return system_proxy_id\n\n        # Fallback - Did not resolve anything, or doesnt exist, use the first available\n        if system_proxy_id is None or not self.proxy_list.get(system_proxy_id):\n            first_default = list(self.proxy_list)[0]\n            return first_default\n\n        return None\n\n    @property\n    def has_extra_headers_file(self):\n        filepath = os.path.join(self.datastore_path, 'headers.txt')\n        return os.path.isfile(filepath)\n\n    def get_all_base_headers(self):\n        headers = {}\n        # Global app settings\n        headers.update(self.data['settings'].get('headers', {}))\n\n        return headers\n\n    def get_all_headers_in_textfile_for_watch(self, uuid):\n        from ..model.App import parse_headers_from_text_file\n        headers = {}\n\n        # Global in /datastore/headers.txt\n        filepath = os.path.join(self.datastore_path, 'headers.txt')\n        try:\n            if os.path.isfile(filepath):\n                headers.update(parse_headers_from_text_file(filepath))\n        except Exception as e:\n            logger.error(f\"ERROR reading headers.txt at {filepath} {str(e)}\")\n\n        watch = self.data['watching'].get(uuid)\n        if watch:\n\n            # In /datastore/xyz-xyz/headers.txt\n            filepath = os.path.join(watch.data_dir, 'headers.txt')\n            try:\n                if os.path.isfile(filepath):\n                    headers.update(parse_headers_from_text_file(filepath))\n            except Exception as e:\n                logger.error(f\"ERROR reading headers.txt at {filepath} {str(e)}\")\n\n            # In /datastore/tag-name.txt\n            tags = self.get_all_tags_for_watch(uuid=uuid)\n            for tag_uuid, tag in tags.items():\n                fname = \"headers-\" + re.sub(r'[\\W_]', '', tag.get('title')).lower().strip() + \".txt\"\n                filepath = os.path.join(self.datastore_path, fname)\n                try:\n                    if os.path.isfile(filepath):\n                        headers.update(parse_headers_from_text_file(filepath))\n                except Exception as e:\n                    logger.error(f\"ERROR reading headers.txt at {filepath} {str(e)}\")\n\n        return headers\n\n    def get_tag_overrides_for_watch(self, uuid, attr):\n        tags = self.get_all_tags_for_watch(uuid=uuid)\n        ret = []\n\n        if tags:\n            for tag_uuid, tag in tags.items():\n                if attr in tag and tag[attr]:\n                    ret = [*ret, *tag[attr]]\n\n        return ret\n\n    def add_tag(self, title):\n        # If name exists, return that\n        n = title.strip().lower()\n        logger.debug(f\">>> Adding new tag - '{n}'\")\n        if not n:\n            return False\n\n        for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():\n            if n == tag.get('title', '').lower().strip():\n                logger.warning(f\"Tag '{title}' already exists, skipping creation.\")\n                return uuid\n\n        # Eventually almost everything todo with a watch will apply as a Tag\n        # So we use the same model as a Watch\n        with self.lock:\n            from ..model import Tag\n            new_tag = Tag.model(\n                datastore_path=self.datastore_path,\n                __datastore=self.__data,\n                default={\n                    'title': title.strip(),\n                    'date_created': int(time.time())\n                }\n            )\n\n            new_uuid = new_tag.get('uuid')\n\n            self.__data['settings']['application']['tags'][new_uuid] = new_tag\n\n        # Save tag to its own tag.json file instead of settings\n        new_tag.commit()\n        return new_uuid\n\n    def get_all_tags_for_watch(self, uuid):\n        \"\"\"This should be in Watch model but Watch doesn't have access to datastore, not sure how to solve that yet\"\"\"\n        watch = self.data['watching'].get(uuid)\n\n        # Should return a dict of full tag info linked by UUID\n        if watch:\n            return dictfilt(self.__data['settings']['application']['tags'], watch.get('tags', []))\n\n        return {}\n\n    @property\n    def extra_browsers(self):\n        res = []\n        p = list(filter(\n            lambda s: (s.get('browser_name') and s.get('browser_connection_url')),\n            self.__data['settings']['requests'].get('extra_browsers', [])))\n        if p:\n            for i in p:\n                res.append((\"extra_browser_\" + i['browser_name'], i['browser_name']))\n\n        return res\n\n    def tag_exists_by_name(self, tag_name):\n        # Check if any tag dictionary has a 'title' attribute matching the provided tag_name\n        tags = self.__data['settings']['application']['tags'].values()\n        return next((v for v in tags if v.get('title', '').lower() == tag_name.lower()),\n                    None)\n\n    def any_watches_have_processor_by_name(self, processor_name):\n        for watch in self.data['watching'].values():\n            if watch.get('processor') == processor_name:\n                return True\n        return False\n\n    def search_watches_for_url(self, query, tag_limit=None, partial=False):\n        \"\"\"Search watches by URL, title, or error messages\n\n        Args:\n            query (str): Search term to match against watch URLs, titles, and error messages\n            tag_limit (str, optional): Optional tag name to limit search results\n            partial: (bool, optional): sub-string matching\n\n        Returns:\n            list: List of UUIDs of watches that match the search criteria\n        \"\"\"\n        matching_uuids = []\n        query = query.lower().strip()\n        tag = self.tag_exists_by_name(tag_limit) if tag_limit else False\n\n        for uuid, watch in self.data['watching'].items():\n            # Filter by tag if requested\n            if tag_limit:\n                if not tag.get('uuid') in watch.get('tags', []):\n                    continue\n\n            # Search in URL, title, or error messages\n            if partial:\n                if ((watch.get('title') and query in watch.get('title').lower()) or\n                        query in watch.get('url', '').lower() or\n                        (watch.get('last_error') and query in watch.get('last_error').lower())):\n                    matching_uuids.append(uuid)\n            else:\n                if ((watch.get('title') and query == watch.get('title').lower()) or\n                        query == watch.get('url', '').lower() or\n                        (watch.get('last_error') and query == watch.get('last_error').lower())):\n                    matching_uuids.append(uuid)\n\n        return matching_uuids\n\n    def get_unique_notification_tokens_available(self):\n        # Ask each type of watch if they have any extra notification token to add to the validation\n        extra_notification_tokens = {}\n        watch_processors_checked = set()\n\n        for watch_uuid, watch in self.__data['watching'].items():\n            processor = watch.get('processor')\n            if processor not in watch_processors_checked:\n                extra_notification_tokens.update(watch.extra_notification_token_values())\n                watch_processors_checked.add(processor)\n\n        return extra_notification_tokens\n\n    def get_unique_notification_token_placeholders_available(self):\n        # The actual description of the tokens, could be combined with get_unique_notification_tokens_available instead of doing this twice\n        extra_notification_tokens = []\n        watch_processors_checked = set()\n\n        for watch_uuid, watch in self.__data['watching'].items():\n            processor = watch.get('processor')\n            if processor not in watch_processors_checked:\n                extra_notification_tokens += watch.extra_notification_token_placeholder_info()\n                watch_processors_checked.add(processor)\n\n        return extra_notification_tokens\n\n    def add_notification_url(self, notification_url):\n\n        logger.debug(f\">>> Adding new notification_url - '{notification_url}'\")\n\n        notification_urls = self.data['settings']['application'].get('notification_urls', [])\n\n        if notification_url in notification_urls:\n            return notification_url\n\n        with self.lock:\n            notification_urls = self.__data['settings']['application'].get('notification_urls', [])\n\n            if notification_url in notification_urls:\n                return notification_url\n\n            # Append and update the datastore\n            notification_urls.append(notification_url)\n            self.__data['settings']['application']['notification_urls'] = notification_urls\n\n        self.commit()\n        return notification_url\n\n    # Schema update methods moved to store/updates.py (DatastoreUpdatesMixin)\n    # This includes: get_updates_available(), run_updates(), and update_1() through update_26()\n"
  },
  {
    "path": "changedetectionio/store/base.py",
    "content": "\"\"\"\nBase classes for the datastore.\n\nThis module defines the abstract interfaces that all datastore implementations must follow.\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom threading import Lock\nfrom loguru import logger\n\n\nclass DataStore(ABC):\n    \"\"\"\n    Abstract base class for all datastore implementations.\n\n    Defines the core interface that all datastores must implement for:\n    - Loading and saving data\n    - Managing watches\n    - Handling settings\n    - Providing data access\n    \"\"\"\n\n    lock = Lock()\n    datastore_path = None\n\n    @abstractmethod\n    def reload_state(self, datastore_path, include_default_watches, version_tag):\n        \"\"\"\n        Load data from persistent storage.\n\n        Args:\n            datastore_path: Path to the datastore directory\n            include_default_watches: Whether to create default watches if none exist\n            version_tag: Application version string\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def add_watch(self, url, **kwargs):\n        \"\"\"\n        Add a new watch.\n\n        Args:\n            url: URL to watch\n            **kwargs: Additional watch parameters\n\n        Returns:\n            UUID of the created watch\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def update_watch(self, uuid, update_obj):\n        \"\"\"\n        Update an existing watch.\n\n        Args:\n            uuid: Watch UUID\n            update_obj: Dictionary of fields to update\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def delete(self, uuid):\n        \"\"\"\n        Delete a watch.\n\n        Args:\n            uuid: Watch UUID to delete\n        \"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def data(self):\n        \"\"\"\n        Access to the underlying data structure.\n\n        Returns:\n            Dictionary containing all datastore data\n        \"\"\"\n        pass\n\n"
  },
  {
    "path": "changedetectionio/store/file_saving_datastore.py",
    "content": "\"\"\"\nFile-based datastore with individual watch persistence and immediate commits.\n\nThis module provides the FileSavingDataStore abstract class that implements:\n- Individual watch.json file persistence\n- Immediate commit-based persistence (watch.commit(), datastore.commit())\n- Atomic file writes safe for NFS/NAS\n\"\"\"\n\nimport glob\nimport json\nimport os\nimport tempfile\nimport time\nfrom loguru import logger\n\nfrom .base import DataStore\nfrom .. import strtobool\n\n# Try to import orjson for faster JSON serialization\ntry:\n    import orjson\n    HAS_ORJSON = True\nexcept ImportError:\n    HAS_ORJSON = False\n\n# Fsync configuration: Force file data to disk for crash safety\n# Default False to match legacy behavior (write-and-rename without fsync)\n# Set to True for mission-critical deployments requiring crash consistency\nFORCE_FSYNC_DATA_IS_CRITICAL = bool(strtobool(os.getenv('FORCE_FSYNC_DATA_IS_CRITICAL', 'False')))\n\n# ============================================================================\n# Helper Functions for Atomic File Operations\n# ============================================================================\n\ndef save_json_atomic(file_path, data_dict, label=\"file\", max_size_mb=10):\n    \"\"\"\n    Save JSON data to disk using atomic write pattern.\n\n    Generic helper for saving any JSON data (settings, watches, etc.) with:\n    - Atomic write (temp file + rename)\n    - Directory fsync for crash consistency (only for new files)\n    - Size validation\n    - Proper error handling\n\n    Thread safety: Caller must hold datastore.lock to prevent concurrent modifications.\n    Multi-process safety: Not supported - run only one app instance per datastore.\n\n    Args:\n        file_path: Full path to target JSON file\n        data_dict: Dictionary to serialize\n        label: Human-readable label for error messages (e.g., \"watch\", \"settings\")\n        max_size_mb: Maximum allowed file size in MB\n\n    Raises:\n        ValueError: If serialized data exceeds max_size_mb\n        OSError: If disk is full (ENOSPC) or other I/O error\n    \"\"\"\n    # Check if file already exists (before we start writing)\n    # Directory fsync only needed for NEW files to persist the filename\n    file_exists = os.path.exists(file_path)\n\n    # Ensure parent directory exists\n    parent_dir = os.path.dirname(file_path)\n    os.makedirs(parent_dir, exist_ok=True)\n\n    # Create temp file in same directory (required for NFS atomicity)\n    fd, temp_path = tempfile.mkstemp(\n        suffix='.tmp',\n        prefix='json-',\n        dir=parent_dir,\n        text=False\n    )\n\n    fd_closed = False\n    try:\n        # Serialize data\n        t0 = time.time()\n        if HAS_ORJSON:\n            data = orjson.dumps(data_dict, option=orjson.OPT_INDENT_2)\n        else:\n            data = json.dumps(data_dict, indent=2, ensure_ascii=False).encode('utf-8')\n        serialize_ms = (time.time() - t0) * 1000\n\n        # Safety check: validate size\n        MAX_SIZE = max_size_mb * 1024 * 1024\n        data_size = len(data)\n        if data_size > MAX_SIZE:\n            raise ValueError(\n                f\"{label.capitalize()} data is unexpectedly large: {data_size / 1024 / 1024:.2f}MB \"\n                f\"(max: {max_size_mb}MB). This indicates a bug or data corruption.\"\n            )\n\n        # Write to temp file\n        t1 = time.time()\n        os.write(fd, data)\n        write_ms = (time.time() - t1) * 1000\n\n        # Optional fsync: Force file data to disk for crash safety\n        # Only if FORCE_FSYNC_DATA_IS_CRITICAL=True (default: False, matches legacy behavior)\n        t2 = time.time()\n        if FORCE_FSYNC_DATA_IS_CRITICAL:\n            os.fsync(fd)\n        file_fsync_ms = (time.time() - t2) * 1000\n\n        os.close(fd)\n        fd_closed = True\n\n        # Atomic rename\n        t3 = time.time()\n        os.replace(temp_path, file_path)\n        rename_ms = (time.time() - t3) * 1000\n\n        # Sync directory to ensure filename metadata is durable\n        # OPTIMIZATION: Only needed for NEW files. Existing files already have\n        # directory entry persisted, so we only need file fsync for data durability.\n        dir_fsync_ms = 0\n        if not file_exists:\n            try:\n                dir_fd = os.open(parent_dir, os.O_RDONLY)\n                try:\n                    t4 = time.time()\n                    os.fsync(dir_fd)\n                    dir_fsync_ms = (time.time() - t4) * 1000\n                finally:\n                    os.close(dir_fd)\n            except (OSError, AttributeError):\n                # Windows doesn't support fsync on directories\n                pass\n\n        # Log timing breakdown for slow saves\n#        total_ms = serialize_ms + write_ms + file_fsync_ms + rename_ms + dir_fsync_ms\n#        if total_ms:  # Log if save took more than 10ms\n#            file_status = \"new\" if not file_exists else \"update\"\n#            logger.trace(\n#                f\"Save timing breakdown ({total_ms:.1f}ms total, {file_status}): \"\n#                f\"serialize={serialize_ms:.1f}ms, write={write_ms:.1f}ms, \"\n#                f\"file_fsync={file_fsync_ms:.1f}ms, rename={rename_ms:.1f}ms, \"\n#                f\"dir_fsync={dir_fsync_ms:.1f}ms, using_orjson={HAS_ORJSON}\"\n#            )\n\n    except OSError as e:\n        # Cleanup temp file\n        if not fd_closed:\n            try:\n                os.close(fd)\n            except:\n                pass\n        if os.path.exists(temp_path):\n            try:\n                os.unlink(temp_path)\n            except:\n                pass\n\n        # Provide helpful error messages\n        if e.errno == 28:  # ENOSPC\n            raise OSError(f\"Disk full: Cannot save {label}\") from e\n        elif e.errno == 122:  # EDQUOT\n            raise OSError(f\"Disk quota exceeded: Cannot save {label}\") from e\n        else:\n            raise OSError(f\"I/O error saving {label}: {e}\") from e\n\n    except Exception as e:\n        # Cleanup temp file\n        if not fd_closed:\n            try:\n                os.close(fd)\n            except:\n                pass\n        if os.path.exists(temp_path):\n            try:\n                os.unlink(temp_path)\n            except:\n                pass\n        raise e\n\n\ndef save_entity_atomic(entity_dir, uuid, entity_dict, filename, entity_type, max_size_mb):\n    \"\"\"\n    Save an entity (watch/tag) to disk using atomic write pattern.\n\n    Generic function for saving any watch_base subclass (Watch, Tag, etc.).\n\n    Args:\n        entity_dir: Directory for this entity (e.g., /datastore/{uuid})\n        uuid: Entity UUID (for logging)\n        entity_dict: Dictionary representation of the entity\n        filename: JSON filename (e.g., 'watch.json', 'tag.json')\n        entity_type: Type label for logging (e.g., 'watch', 'tag')\n        max_size_mb: Maximum allowed file size in MB\n\n    Raises:\n        ValueError: If serialized data exceeds max_size_mb\n        OSError: If disk is full (ENOSPC) or other I/O error\n    \"\"\"\n    entity_json = os.path.join(entity_dir, filename)\n    save_json_atomic(entity_json, entity_dict, label=f\"{entity_type} {uuid}\", max_size_mb=max_size_mb)\n\n\ndef save_watch_atomic(watch_dir, uuid, watch_dict):\n    \"\"\"\n    Save a watch to disk using atomic write pattern.\n\n    Convenience wrapper around save_entity_atomic for watches.\n    Kept for backwards compatibility.\n    \"\"\"\n    save_entity_atomic(watch_dir, uuid, watch_dict, \"watch.json\", \"watch\", max_size_mb=10)\n\n\n\ndef load_watch_from_file(watch_json, uuid, rehydrate_entity_func):\n    \"\"\"\n    Load a watch from its JSON file.\n\n    Args:\n        watch_json: Path to the watch.json file\n        uuid: Watch UUID\n        rehydrate_entity_func: Function to convert dict to Watch object\n\n    Returns:\n        Watch object or None if failed\n    \"\"\"\n    try:\n        # Check file size before reading\n        file_size = os.path.getsize(watch_json)\n        MAX_WATCH_SIZE = 10 * 1024 * 1024  # 10MB\n        if file_size > MAX_WATCH_SIZE:\n            logger.critical(\n                f\"CORRUPTED WATCH DATA: Watch {uuid} file is unexpectedly large: \"\n                f\"{file_size / 1024 / 1024:.2f}MB (max: {MAX_WATCH_SIZE / 1024 / 1024}MB). \"\n                f\"File: {watch_json}. This indicates a bug or data corruption. \"\n                f\"Watch will be skipped.\"\n            )\n            return None\n\n        if HAS_ORJSON:\n            with open(watch_json, 'rb') as f:\n                watch_data = orjson.loads(f.read())\n        else:\n            with open(watch_json, 'r', encoding='utf-8') as f:\n                watch_data = json.load(f)\n\n        # Rehydrate and return watch object\n        watch_obj = rehydrate_entity_func(uuid, watch_data)\n        return watch_obj\n\n    except json.JSONDecodeError as e:\n        logger.critical(\n            f\"CORRUPTED WATCH DATA: Failed to parse JSON for watch {uuid}. \"\n            f\"File: {watch_json}. Error: {e}. \"\n            f\"Watch will be skipped and may need manual recovery from backup.\"\n        )\n        return None\n    except ValueError as e:\n        # orjson raises ValueError for invalid JSON\n        if \"invalid json\" in str(e).lower() or HAS_ORJSON:\n            logger.critical(\n                f\"CORRUPTED WATCH DATA: Failed to parse JSON for watch {uuid}. \"\n                f\"File: {watch_json}. Error: {e}. \"\n                f\"Watch will be skipped and may need manual recovery from backup.\"\n            )\n            return None\n        # Re-raise if it's not a JSON parsing error\n        raise\n    except FileNotFoundError:\n        logger.error(f\"Watch file not found: {watch_json} for watch {uuid}\")\n        return None\n    except Exception as e:\n        logger.error(f\"Failed to load watch {uuid} from {watch_json}: {e}\")\n        return None\n\n\ndef load_all_watches(datastore_path, rehydrate_entity_func):\n    \"\"\"\n    Load all watches from individual watch.json files.\n\n    SYNCHRONOUS loading: Blocks until all watches are loaded.\n    This ensures data consistency - web server won't accept requests\n    until all watches are available. Progress logged every 100 watches.\n\n    Args:\n        datastore_path: Path to the datastore directory\n        rehydrate_entity_func: Function to convert dict to Watch object\n\n    Returns:\n        Dictionary of uuid -> Watch object\n    \"\"\"\n    start_time = time.time()\n    logger.info(\"Loading watches from individual watch.json files...\")\n\n    watching = {}\n\n    if not os.path.exists(datastore_path):\n        return watching\n\n    # Find all watch.json files using glob (faster than manual directory traversal)\n    glob_start = time.time()\n    watch_files = glob.glob(os.path.join(datastore_path, \"*\", \"watch.json\"))\n    glob_time = time.time() - glob_start\n\n    total = len(watch_files)\n    logger.debug(f\"Found {total} watch.json files in {glob_time:.3f}s\")\n\n    loaded = 0\n    failed = 0\n\n    for watch_json in watch_files:\n        # Extract UUID from path: /datastore/{uuid}/watch.json\n        uuid_dir = os.path.basename(os.path.dirname(watch_json))\n        watch = load_watch_from_file(watch_json, uuid_dir, rehydrate_entity_func)\n        if watch:\n            watching[uuid_dir] = watch\n            loaded += 1\n\n            if loaded % 100 == 0:\n                logger.info(f\"Loaded {loaded}/{total} watches...\")\n        else:\n            # load_watch_from_file already logged the specific error\n            failed += 1\n\n    elapsed = time.time() - start_time\n\n    if failed > 0:\n        logger.critical(\n            f\"LOAD COMPLETE: {loaded} watches loaded successfully, \"\n            f\"{failed} watches FAILED to load (corrupted or invalid) \"\n            f\"in {elapsed:.2f}s ({loaded/elapsed:.0f} watches/sec)\"\n        )\n    else:\n        logger.info(f\"Loaded {loaded} watches from disk in {elapsed:.2f}s ({loaded/elapsed:.0f} watches/sec)\")\n\n    return watching\n\n\ndef load_tag_from_file(tag_json, uuid, rehydrate_entity_func):\n    \"\"\"\n    Load a tag from its JSON file.\n\n    Args:\n        tag_json: Path to the tag.json file\n        uuid: Tag UUID\n        rehydrate_entity_func: Function to convert dict to Tag object\n\n    Returns:\n        Tag object or None if failed\n    \"\"\"\n    try:\n        # Check file size before reading\n        file_size = os.path.getsize(tag_json)\n        MAX_TAG_SIZE = 1 * 1024 * 1024  # 1MB\n        if file_size > MAX_TAG_SIZE:\n            logger.critical(\n                f\"CORRUPTED TAG DATA: Tag {uuid} file is unexpectedly large: \"\n                f\"{file_size / 1024 / 1024:.2f}MB (max: {MAX_TAG_SIZE / 1024 / 1024}MB). \"\n                f\"File: {tag_json}. This indicates a bug or data corruption. \"\n                f\"Tag will be skipped.\"\n            )\n            return None\n\n        if HAS_ORJSON:\n            with open(tag_json, 'rb') as f:\n                tag_data = orjson.loads(f.read())\n        else:\n            with open(tag_json, 'r', encoding='utf-8') as f:\n                tag_data = json.load(f)\n\n        tag_data['processor'] = 'restock_diff'\n        # Rehydrate tag (convert dict to Tag object)\n        # processor_override is set inside the rehydration function\n        tag_obj = rehydrate_entity_func(uuid, tag_data)\n        return tag_obj\n\n    except json.JSONDecodeError as e:\n        logger.critical(\n            f\"CORRUPTED TAG DATA: Failed to parse JSON for tag {uuid}. \"\n            f\"File: {tag_json}. Error: {e}. \"\n            f\"Tag will be skipped and may need manual recovery from backup.\"\n        )\n        return None\n    except ValueError as e:\n        # orjson raises ValueError for invalid JSON\n        if \"invalid json\" in str(e).lower() or HAS_ORJSON:\n            logger.critical(\n                f\"CORRUPTED TAG DATA: Failed to parse JSON for tag {uuid}. \"\n                f\"File: {tag_json}. Error: {e}. \"\n                f\"Tag will be skipped and may need manual recovery from backup.\"\n            )\n            return None\n        # Re-raise if it's not a JSON parsing error\n        raise\n    except FileNotFoundError:\n        logger.debug(f\"Tag file not found: {tag_json} for tag {uuid}\")\n        return None\n    except Exception as e:\n        logger.error(f\"Failed to load tag {uuid} from {tag_json}: {e}\")\n        return None\n\n\ndef load_all_tags(datastore_path, rehydrate_entity_func):\n    \"\"\"\n    Load all tags from individual tag.json files.\n\n    Tags are stored separately from settings in {uuid}/tag.json files.\n\n    Args:\n        datastore_path: Path to the datastore directory\n        rehydrate_entity_func: Function to convert dict to Tag object\n\n    Returns:\n        Dictionary of uuid -> Tag object\n    \"\"\"\n    logger.info(\"Loading tags from individual tag.json files...\")\n\n    tags = {}\n\n    if not os.path.exists(datastore_path):\n        return tags\n\n    # Find all tag.json files using glob\n    tag_files = glob.glob(os.path.join(datastore_path, \"*\", \"tag.json\"))\n\n    total = len(tag_files)\n    if total == 0:\n        logger.debug(\"No tag.json files found\")\n        return tags\n\n    logger.debug(f\"Found {total} tag.json files\")\n\n    loaded = 0\n    failed = 0\n\n    for tag_json in tag_files:\n        # Extract UUID from path: /datastore/{uuid}/tag.json\n        uuid_dir = os.path.basename(os.path.dirname(tag_json))\n        tag = load_tag_from_file(tag_json, uuid_dir, rehydrate_entity_func)\n        if tag:\n            tags[uuid_dir] = tag\n            loaded += 1\n        else:\n            # load_tag_from_file already logged the specific error\n            failed += 1\n\n    if failed > 0:\n        logger.warning(f\"Loaded {loaded} tags, {failed} tags FAILED to load\")\n    else:\n        logger.info(f\"Loaded {loaded} tags from disk\")\n\n    return tags\n\n\n# ============================================================================\n# FileSavingDataStore Class\n# ============================================================================\n\nclass FileSavingDataStore(DataStore):\n    \"\"\"\n    Abstract datastore that provides file persistence with immediate commits.\n\n    Features:\n    - Individual watch.json files (one per watch)\n    - Immediate persistence via watch.commit() and datastore.commit()\n    - Atomic file writes for crash safety\n\n    Subclasses must implement:\n    - rehydrate_entity(): Convert dict to Watch object\n    - Access to internal __data structure for watch management\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n\n    def _save_settings(self):\n        \"\"\"\n        Save settings to storage (polymorphic).\n\n        Subclasses must implement for their backend.\n        - File: changedetection.json\n        - Redis: SET settings\n        - SQL: UPDATE settings table\n        \"\"\"\n        raise NotImplementedError(\"Subclass must implement _save_settings\")\n\n\n    def _load_watches(self):\n        \"\"\"\n        Load all watches from storage (polymorphic).\n\n        Subclasses must implement for their backend.\n        - File: Read individual watch.json files\n        - Redis: SCAN watch:* keys\n        - SQL: SELECT * FROM watches\n        \"\"\"\n        raise NotImplementedError(\"Subclass must implement _load_watches\")\n\n    def _delete_watch(self, uuid):\n        \"\"\"\n        Delete a watch from storage (polymorphic).\n\n        Subclasses must implement for their backend.\n        - File: Delete {uuid}/ directory recursively\n        - Redis: DEL watch:{uuid}\n        - SQL: DELETE FROM watches WHERE uuid=?\n\n        Args:\n            uuid: Watch UUID to delete\n        \"\"\"\n        raise NotImplementedError(\"Subclass must implement _delete_watch\")\n\n\n"
  },
  {
    "path": "changedetectionio/store/updates.py",
    "content": "\"\"\"\nSchema update migrations for the datastore.\n\nThis module contains all schema version upgrade methods (update_1 through update_N).\nThese are mixed into ChangeDetectionStore to keep the main store file focused.\n\nIMPORTANT: Each update could be run even when they have a new install and the schema is correct.\nTherefore - each `update_n` should be very careful about checking if it needs to actually run.\n\"\"\"\n\nimport os\nimport re\nimport shutil\nimport tarfile\nimport time\nfrom loguru import logger\nfrom copy import deepcopy\n\n\n# Try to import orjson for faster JSON serialization\ntry:\n    import orjson\n    HAS_ORJSON = True\nexcept ImportError:\n    HAS_ORJSON = False\n\nfrom ..html_tools import TRANSLATE_WHITESPACE_TABLE\nfrom ..processors.restock_diff import Restock\nfrom ..blueprint.rss import RSS_CONTENT_FORMAT_DEFAULT\nfrom ..model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n\ndef create_backup_tarball(datastore_path, update_number):\n    \"\"\"\n    Create a tarball backup of the entire datastore structure before running an update.\n\n    Includes:\n    - All {uuid}/watch.json files\n    - All {uuid}/tag.json files\n    - changedetection.json (settings, if it exists)\n    - url-watches.json (legacy format, if it exists)\n    - Directory structure preserved\n\n    Args:\n        datastore_path: Path to datastore directory\n        update_number: Update number being applied\n\n    Returns:\n        str: Path to created tarball, or None if backup failed\n\n    Restoration:\n    To restore from a backup:\n        cd /path/to/datastore\n        tar -xzf before-update-N-timestamp.tar.gz\n    This will restore all watch.json and tag.json files and settings to their pre-update state.\n    \"\"\"\n    timestamp = int(time.time())\n    backup_filename = f\"before-update-{update_number}-{timestamp}.tar.gz\"\n    backup_path = os.path.join(datastore_path, backup_filename)\n\n    try:\n        logger.info(f\"Creating backup tarball: {backup_filename}\")\n\n        with tarfile.open(backup_path, \"w:gz\") as tar:\n            # Backup changedetection.json if it exists (new format)\n            changedetection_json = os.path.join(datastore_path, \"changedetection.json\")\n            if os.path.isfile(changedetection_json):\n                tar.add(changedetection_json, arcname=\"changedetection.json\")\n                logger.debug(\"Added changedetection.json to backup\")\n\n            # Backup url-watches.json if it exists (legacy format)\n            url_watches_json = os.path.join(datastore_path, \"url-watches.json\")\n            if os.path.isfile(url_watches_json):\n                tar.add(url_watches_json, arcname=\"url-watches.json\")\n                logger.debug(\"Added url-watches.json to backup\")\n\n            # Backup all watch/tag directories with their JSON files\n            # This preserves the UUID directory structure\n            watch_count = 0\n            tag_count = 0\n            for entry in os.listdir(datastore_path):\n                entry_path = os.path.join(datastore_path, entry)\n\n                # Skip if not a directory\n                if not os.path.isdir(entry_path):\n                    continue\n\n                # Skip hidden directories and backup directories\n                if entry.startswith('.') or entry.startswith('before-update-'):\n                    continue\n\n                # Backup watch.json if exists\n                watch_json = os.path.join(entry_path, \"watch.json\")\n                if os.path.isfile(watch_json):\n                    tar.add(watch_json, arcname=f\"{entry}/watch.json\")\n                    watch_count += 1\n\n                    if watch_count % 100 == 0:\n                        logger.debug(f\"Backed up {watch_count} watch.json files...\")\n\n                # Backup tag.json if exists\n                tag_json = os.path.join(entry_path, \"tag.json\")\n                if os.path.isfile(tag_json):\n                    tar.add(tag_json, arcname=f\"{entry}/tag.json\")\n                    tag_count += 1\n\n            logger.success(f\"Backup created: {backup_filename} ({watch_count} watches from disk, {tag_count} tags from disk)\")\n            return backup_path\n\n    except Exception as e:\n        logger.error(f\"Failed to create backup tarball: {e}\")\n        # Try to clean up partial backup\n        if os.path.exists(backup_path):\n            try:\n                os.unlink(backup_path)\n            except:\n                pass\n        return None\n\n\nclass DatastoreUpdatesMixin:\n    \"\"\"\n    Mixin class containing all schema update methods.\n\n    This class is inherited by ChangeDetectionStore to provide schema migration functionality.\n    Each update_N method upgrades the schema from version N-1 to version N.\n    \"\"\"\n\n    def get_updates_available(self):\n        \"\"\"\n        Discover all available update methods.\n\n        Returns:\n            list: Sorted list of update version numbers (e.g., [1, 2, 3, ..., 26])\n        \"\"\"\n        import inspect\n        updates_available = []\n        for i, o in inspect.getmembers(self, predicate=inspect.ismethod):\n            m = re.search(r'update_(\\d+)$', i)\n            if m:\n                updates_available.append(int(m.group(1)))\n        updates_available.sort()\n\n        return updates_available\n\n    def run_updates(self, current_schema_version=None):\n        import sys\n        \"\"\"\n        Run all pending schema updates sequentially.\n\n        Args:\n            current_schema_version: Optional current schema version. If provided, only run updates\n                                   greater than this version. If None, uses the schema version from\n                                   the datastore. If no schema version exists in datastore and it appears\n                                   to be a fresh install, sets to latest update number (no updates needed).\n\n        IMPORTANT: Each update could be run even when they have a new install and the schema is correct.\n        Therefore - each `update_n` should be very careful about checking if it needs to actually run.\n\n        Process:\n        1. Get list of available updates\n        2. For each update > current schema version:\n           - Create backup of datastore\n           - Run update method\n           - Update schema version and commit settings\n           - Commit all watches and tags\n        3. If any update fails, stop processing\n        4. All changes saved via individual .commit() calls\n        \"\"\"\n        updates_available = self.get_updates_available()\n        if self.data.get('watching'):\n            test_watch = self.data['watching'].get(next(iter(self.data.get('watching', {}))))\n            from ..model.Watch import model\n\n            if not isinstance(test_watch, model):\n                import sys\n                logger.critical(\"Cannot run updates! Watch structure must be re-hydrated back to a Watch model object!\")\n                sys.exit(1)\n\n        if self.data['settings']['application'].get('tags',{}):\n            test_tag = self.data['settings']['application'].get('tags',{}).get(next(iter(self.data['settings']['application'].get('tags',{}))))\n            from ..model.Tag import model as tag_model\n\n            if not isinstance(test_tag, tag_model):\n                import sys\n                logger.critical(\"Cannot run updates! Watch tag/group structure must be re-hydrated back to a Tag model object!\")\n                sys.exit(1)\n\n        # Determine current schema version\n        if current_schema_version is None:\n            # Check if schema_version exists in datastore\n            current_schema_version = self.data['settings']['application'].get('schema_version')\n\n            if current_schema_version is None:\n                # No schema version found - could be a fresh install or very old datastore\n                # If this is a fresh/new config with no watches, assume it's up-to-date\n                # and set to latest update number (no updates needed)\n                if len(self.data['watching']) == 0:\n                    # Get the highest update number from available update methods\n                    latest_update = updates_available[-1] if updates_available else 0\n                    logger.info(f\"No schema version found and no watches exist - assuming fresh install, setting schema_version to {latest_update}\")\n                    self.data['settings']['application']['schema_version'] = latest_update\n                    self.commit()\n                    return  # No updates needed for fresh install\n                else:\n                    # Has watches but no schema version - likely old datastore, run all updates\n                    logger.warning(\"No schema version found but watches exist - running all updates from version 0\")\n                    current_schema_version = 0\n\n        logger.info(f\"Current schema version: {current_schema_version}\")\n\n        updates_ran = []\n\n        for update_n in updates_available:\n            if update_n > current_schema_version:\n                logger.critical(f\"Applying update_{update_n}\")\n\n                # Create tarball backup of entire datastore structure\n                # This includes all watch.json files, settings, and preserves directory structure\n                backup_path = create_backup_tarball(self.datastore_path, update_n)\n                if backup_path:\n                    logger.info(f\"Backup created at: {backup_path}\")\n                else:\n                    logger.warning(\"Backup creation failed, but continuing with update\")\n\n                try:\n                    update_method = getattr(self, f\"update_{update_n}\")()\n                except Exception as e:\n                    logger.critical(f\"Error while trying update_{update_n}\")\n                    logger.exception(e)\n                    sys.exit(1)\n                else:\n                    # Bump the version\n                    self.data['settings']['application']['schema_version'] = update_n\n                    self.commit()\n\n                    logger.success(f\"Update {update_n} completed\")\n\n                    # Track which updates ran\n                    updates_ran.append(update_n)\n\n    # ============================================================================\n    # Individual Update Methods\n    # ============================================================================\n\n    def update_1(self):\n        \"\"\"Convert minutes to seconds on settings and each watch.\"\"\"\n        if self.data['settings']['requests'].get('minutes_between_check'):\n            self.data['settings']['requests']['time_between_check']['minutes'] = self.data['settings']['requests']['minutes_between_check']\n            # Remove the default 'hours' that is set from the model\n            self.data['settings']['requests']['time_between_check']['hours'] = None\n\n        for uuid, watch in self.data['watching'].items():\n            if 'minutes_between_check' in watch:\n                # Only upgrade individual watch time if it was set\n                if watch.get('minutes_between_check', False):\n                    self.data['watching'][uuid]['time_between_check']['minutes'] = watch['minutes_between_check']\n\n    def update_2(self):\n        \"\"\"\n        Move the history list to a flat text file index.\n        Better than SQLite because this list is only appended to, and works across NAS / NFS type setups.\n        \"\"\"\n        # @todo test running this on a newly updated one (when this already ran)\n        for uuid, watch in self.data['watching'].items():\n            history = []\n\n            if watch.get('history', False):\n                for d, p in watch['history'].items():\n                    d = int(d)  # Used to be keyed as str, we'll fix this now too\n                    history.append(\"{},{}\\n\".format(d, p))\n\n                if len(history):\n                    target_path = os.path.join(self.datastore_path, uuid)\n                    if os.path.exists(target_path):\n                        with open(os.path.join(target_path, \"history.txt\"), \"w\") as f:\n                            f.writelines(history)\n                    else:\n                        logger.warning(f\"Datastore history directory {target_path} does not exist, skipping history import.\")\n\n                # No longer needed, dynamically pulled from the disk when needed.\n                # But we should set it back to a empty dict so we don't break if this schema runs on an earlier version.\n                # In the distant future we can remove this entirely\n                self.data['watching'][uuid]['history'] = {}\n\n    def update_3(self):\n        \"\"\"We incorrectly stored last_changed when there was not a change, and then confused the output list table.\"\"\"\n        # see https://github.com/dgtlmoon/changedetection.io/pull/835\n        return\n\n    def update_4(self):\n        \"\"\"`last_changed` not needed, we pull that information from the history.txt index.\"\"\"\n        for uuid, watch in self.data['watching'].items():\n            try:\n                # Remove it from the struct\n                del(watch['last_changed'])\n            except:\n                continue\n        return\n\n    def update_5(self):\n        \"\"\"\n        If the watch notification body, title look the same as the global one, unset it, so the watch defaults back to using the main settings.\n        In other words - the watch notification_title and notification_body are not needed if they are the same as the default one.\n        \"\"\"\n        current_system_body = self.data['settings']['application']['notification_body'].translate(TRANSLATE_WHITESPACE_TABLE)\n        current_system_title = self.data['settings']['application']['notification_body'].translate(TRANSLATE_WHITESPACE_TABLE)\n        for uuid, watch in self.data['watching'].items():\n            try:\n                watch_body = watch.get('notification_body', '')\n                if watch_body and watch_body.translate(TRANSLATE_WHITESPACE_TABLE) == current_system_body:\n                    # Looks the same as the default one, so unset it\n                    watch['notification_body'] = None\n\n                watch_title = watch.get('notification_title', '')\n                if watch_title and watch_title.translate(TRANSLATE_WHITESPACE_TABLE) == current_system_title:\n                    # Looks the same as the default one, so unset it\n                    watch['notification_title'] = None\n            except Exception as e:\n                continue\n        return\n\n    def update_7(self):\n        \"\"\"\n        We incorrectly used common header overrides that should only apply to Requests.\n        These are now handled in content_fetcher::html_requests and shouldnt be passed to Playwright/Selenium.\n        \"\"\"\n        # These were hard-coded in early versions\n        for v in ['User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language']:\n            if self.data['settings']['headers'].get(v):\n                del self.data['settings']['headers'][v]\n\n    def update_8(self):\n        \"\"\"Convert filters to a list of filters css_filter -> include_filters.\"\"\"\n        for uuid, watch in self.data['watching'].items():\n            try:\n                existing_filter = watch.get('css_filter', '')\n                if existing_filter:\n                    watch['include_filters'] = [existing_filter]\n            except:\n                continue\n        return\n\n    def update_9(self):\n        \"\"\"Convert old static notification tokens to jinja2 tokens.\"\"\"\n        # Each watch\n        # only { } not {{ or }}\n        r = r'(?<!{){(?!{)(\\w+)(?<!})}(?!})'\n        for uuid, watch in self.data['watching'].items():\n            try:\n                n_body = watch.get('notification_body', '')\n                if n_body:\n                    watch['notification_body'] = re.sub(r, r'{{\\1}}', n_body)\n\n                n_title = watch.get('notification_title')\n                if n_title:\n                    watch['notification_title'] = re.sub(r, r'{{\\1}}', n_title)\n\n                n_urls = watch.get('notification_urls')\n                if n_urls:\n                    for i, url in enumerate(n_urls):\n                        watch['notification_urls'][i] = re.sub(r, r'{{\\1}}', url)\n\n            except:\n                continue\n\n        # System wide\n        n_body = self.data['settings']['application'].get('notification_body')\n        if n_body:\n            self.data['settings']['application']['notification_body'] = re.sub(r, r'{{\\1}}', n_body)\n\n        n_title = self.data['settings']['application'].get('notification_title')\n        if n_body:\n            self.data['settings']['application']['notification_title'] = re.sub(r, r'{{\\1}}', n_title)\n\n        n_urls = self.data['settings']['application'].get('notification_urls')\n        if n_urls:\n            for i, url in enumerate(n_urls):\n                self.data['settings']['application']['notification_urls'][i] = re.sub(r, r'{{\\1}}', url)\n\n        return\n\n    def update_10(self):\n        \"\"\"Some setups may have missed the correct default, so it shows the wrong config in the UI, although it will default to system-wide.\"\"\"\n        for uuid, watch in self.data['watching'].items():\n            try:\n                if not watch.get('fetch_backend', ''):\n                    watch['fetch_backend'] = 'system'\n            except:\n                continue\n        return\n\n    def update_12(self):\n        \"\"\"Create tag objects and their references from existing tag text.\"\"\"\n        i = 0\n        for uuid, watch in self.data['watching'].items():\n            # Split out and convert old tag string\n            tag = watch.get('tag')\n            if tag:\n                tag_uuids = []\n                for t in tag.split(','):\n                    tag_uuids.append(self.add_tag(title=t))\n\n                self.data['watching'][uuid]['tags'] = tag_uuids\n\n    def update_13(self):\n        \"\"\"#1775 - Update 11 did not update the records correctly when adding 'date_created' values for sorting.\"\"\"\n        i = 0\n        for uuid, watch in self.data['watching'].items():\n            if not watch.get('date_created'):\n                self.data['watching'][uuid]['date_created'] = i\n            i += 1\n        return\n\n    def update_14(self):\n        \"\"\"#1774 - protect xpath1 against migration.\"\"\"\n        for awatch in self.data[\"watching\"]:\n            if self.data[\"watching\"][awatch]['include_filters']:\n                for num, selector in enumerate(self.data[\"watching\"][awatch]['include_filters']):\n                    if selector.startswith('/'):\n                        self.data[\"watching\"][awatch]['include_filters'][num] = 'xpath1:' + selector\n                    if selector.startswith('xpath:'):\n                        self.data[\"watching\"][awatch]['include_filters'][num] = selector.replace('xpath:', 'xpath1:', 1)\n\n    def update_15(self):\n        \"\"\"Use more obvious default time setting.\"\"\"\n        for uuid in self.data[\"watching\"]:\n            if self.data[\"watching\"][uuid]['time_between_check'] == self.data['settings']['requests']['time_between_check']:\n                # What the old logic was, which was pretty confusing\n                self.data[\"watching\"][uuid]['time_between_check_use_default'] = True\n            elif all(value is None or value == 0 for value in self.data[\"watching\"][uuid]['time_between_check'].values()):\n                self.data[\"watching\"][uuid]['time_between_check_use_default'] = True\n            else:\n                # Something custom here\n                self.data[\"watching\"][uuid]['time_between_check_use_default'] = False\n\n    def update_16(self):\n        \"\"\"Correctly set datatype for older installs where 'tag' was string and update_12 did not catch it.\"\"\"\n        for uuid, watch in self.data['watching'].items():\n            if isinstance(watch.get('tags'), str):\n                self.data['watching'][uuid]['tags'] = []\n\n    def update_17(self):\n        \"\"\"Migrate old 'in_stock' values to the new Restock.\"\"\"\n        for uuid, watch in self.data['watching'].items():\n            if 'in_stock' in watch:\n                watch['restock'] = Restock({'in_stock': watch.get('in_stock')})\n                del watch['in_stock']\n\n    def update_18(self):\n        \"\"\"Migrate old restock settings.\"\"\"\n        for uuid, watch in self.data['watching'].items():\n            if not watch.get('restock_settings'):\n                # So we enable price following by default\n                self.data['watching'][uuid]['restock_settings'] = {'follow_price_changes': True}\n\n            # Migrate and cleanoff old value\n            self.data['watching'][uuid]['restock_settings']['in_stock_processing'] = 'in_stock_only' if watch.get(\n                'in_stock_only') else 'all_changes'\n\n            if self.data['watching'][uuid].get('in_stock_only'):\n                del (self.data['watching'][uuid]['in_stock_only'])\n\n    def update_19(self):\n        \"\"\"Compress old elements.json to elements.deflate, saving disk, this compression is pretty fast.\"\"\"\n        import zlib\n\n        for uuid, watch in self.data['watching'].items():\n            json_path = os.path.join(self.datastore_path, uuid, \"elements.json\")\n            deflate_path = os.path.join(self.datastore_path, uuid, \"elements.deflate\")\n\n            if os.path.exists(json_path):\n                with open(json_path, \"rb\") as f_j:\n                    with open(deflate_path, \"wb\") as f_d:\n                        logger.debug(f\"Compressing {str(json_path)} to {str(deflate_path)}..\")\n                        f_d.write(zlib.compress(f_j.read()))\n                        os.unlink(json_path)\n\n    def update_20(self):\n        \"\"\"Migrate extract_title_as_title to use_page_title_in_list.\"\"\"\n        for uuid, watch in self.data['watching'].items():\n            if self.data['watching'][uuid].get('extract_title_as_title'):\n                self.data['watching'][uuid]['use_page_title_in_list'] = self.data['watching'][uuid].get('extract_title_as_title')\n                del self.data['watching'][uuid]['extract_title_as_title']\n\n        if self.data['settings']['application'].get('extract_title_as_title'):\n            # Ensure 'ui' key exists (defensive for edge cases where base_config merge didn't happen)\n            if 'ui' not in self.data['settings']['application']:\n                self.data['settings']['application']['ui'] = {\n                    'use_page_title_in_list': True,\n                    'open_diff_in_new_tab': True,\n                    'socket_io_enabled': True,\n                    'favicons_enabled': True\n                }\n            self.data['settings']['application']['ui']['use_page_title_in_list'] = self.data['settings']['application'].get('extract_title_as_title')\n\n    def update_21(self):\n        \"\"\"Migrate timezone to scheduler_timezone_default.\"\"\"\n        if self.data['settings']['application'].get('timezone'):\n            self.data['settings']['application']['scheduler_timezone_default'] = self.data['settings']['application'].get('timezone')\n            del self.data['settings']['application']['timezone']\n\n    def update_23(self):\n        \"\"\"Some notification formats got the wrong name type.\"\"\"\n\n        def re_run(formats):\n            sys_n_format = self.data['settings']['application'].get('notification_format')\n            key_exists_as_value = next((k for k, v in formats.items() if v == sys_n_format), None)\n            if key_exists_as_value:  # key of \"Plain text\"\n                logger.success(f\"['settings']['application']['notification_format'] '{sys_n_format}' -> '{key_exists_as_value}'\")\n                self.data['settings']['application']['notification_format'] = key_exists_as_value\n\n            for uuid, watch in self.data['watching'].items():\n                n_format = self.data['watching'][uuid].get('notification_format')\n                key_exists_as_value = next((k for k, v in formats.items() if v == n_format), None)\n                if key_exists_as_value and key_exists_as_value != USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:  # key of \"Plain text\"\n                    logger.success(f\"['watching'][{uuid}]['notification_format'] '{n_format}' -> '{key_exists_as_value}'\")\n                    self.data['watching'][uuid]['notification_format'] = key_exists_as_value  # should be 'text' or whatever\n\n            for uuid, tag in self.data['settings']['application']['tags'].items():\n                n_format = self.data['settings']['application']['tags'][uuid].get('notification_format')\n                key_exists_as_value = next((k for k, v in formats.items() if v == n_format), None)\n                if key_exists_as_value and key_exists_as_value != USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:  # key of \"Plain text\"\n                    logger.success(\n                        f\"['settings']['application']['tags'][{uuid}]['notification_format'] '{n_format}' -> '{key_exists_as_value}'\")\n                    self.data['settings']['application']['tags'][uuid][\n                        'notification_format'] = key_exists_as_value  # should be 'text' or whatever\n\n        from ..notification import valid_notification_formats\n        formats = deepcopy(valid_notification_formats)\n        re_run(formats)\n        # And in previous versions, it was \"text\" instead of Plain text, Markdown instead of \"Markdown to HTML\"\n        formats['text'] = 'Text'\n        formats['markdown'] = 'Markdown'\n        re_run(formats)\n\n    def update_24(self):\n        \"\"\"RSS types should be inline with the same names as notification types.\"\"\"\n        rss_format = self.data['settings']['application'].get('rss_content_format')\n        if not rss_format or 'text' in rss_format:\n            # might have been 'plaintext, 'plain text' or something\n            self.data['settings']['application']['rss_content_format'] = RSS_CONTENT_FORMAT_DEFAULT\n        elif 'html' in rss_format:\n            self.data['settings']['application']['rss_content_format'] = 'htmlcolor'\n        else:\n            # safe fallback to text\n            self.data['settings']['application']['rss_content_format'] = RSS_CONTENT_FORMAT_DEFAULT\n\n    def update_25(self):\n        \"\"\"Different processors now hold their own history.txt.\"\"\"\n        for uuid, watch in self.data['watching'].items():\n            processor = self.data['watching'][uuid].get('processor')\n            if processor != 'text_json_diff':\n                old_history_txt = os.path.join(self.datastore_path, \"history.txt\")\n                target_history_name = f\"history-{processor}.txt\"\n                if os.path.isfile(old_history_txt) and not os.path.isfile(target_history_name):\n                    new_history_txt = os.path.join(self.datastore_path, target_history_name)\n                    logger.debug(f\"Renaming history index {old_history_txt} to {new_history_txt}...\")\n                    shutil.move(old_history_txt, new_history_txt)\n\n    def migrate_legacy_db_format(self):\n        \"\"\"\n        Migration: Individual watch persistence (COPY-based, safe rollback).\n\n        Loads legacy url-watches.json format and migrates to:\n        - {uuid}/watch.json (per watch)\n        - changedetection.json (settings only)\n\n        IMPORTANT:\n        - A tarball backup (before-update-26-timestamp.tar.gz) is created before migration\n        - url-watches.json is LEFT INTACT for rollback safety\n        - Users can roll back by simply downgrading to the previous version\n        - Or restore from tarball: tar -xzf before-update-26-*.tar.gz\n\n        This is a dedicated migration release - users upgrade at their own pace.\n        \"\"\"\n        logger.critical(\"=\" * 80)\n        logger.critical(\"Running migration: Individual watch persistence (update_26)\")\n        logger.critical(\"COPY-based migration: url-watches.json will remain intact for rollback\")\n        logger.critical(\"=\" * 80)\n\n        # Populate settings from legacy data\n        logger.info(\"Populating settings from legacy data...\")\n        watch_count = len(self.data['watching'])\n        logger.success(f\"Loaded {watch_count} watches from legacy format\")\n\n        # Phase 1: Save all watches to individual files\n        logger.critical(f\"Phase 1/4: Saving {watch_count} watches to individual watch.json files...\")\n\n        saved_count = 0\n        for uuid, watch in self.data['watching'].items():\n            try:\n                watch.commit()\n                saved_count += 1\n\n                if saved_count % 100 == 0:\n                    logger.info(f\"  Progress: {saved_count}/{watch_count} watches migrated...\")\n\n            except Exception as e:\n                logger.error(f\"Failed to save watch {uuid}: {e}\")\n                raise Exception(\n                    f\"Migration failed: Could not save watch {uuid}. \"\n                    f\"url-watches.json remains intact, safe to retry. Error: {e}\"\n                )\n\n        logger.critical(f\"Phase 1 complete: Saved {saved_count} watches\")\n\n        # Phase 2: Verify all files exist\n        logger.critical(\"Phase 2/4: Verifying all watch.json files were created...\")\n\n        missing = []\n        for uuid in self.data['watching'].keys():\n            watch_json = os.path.join(self.datastore_path, uuid, \"watch.json\")\n            if not os.path.isfile(watch_json):\n                missing.append(uuid)\n\n        if missing:\n            raise Exception(\n                f\"Migration failed: {len(missing)} watch files missing: {missing[:5]}... \"\n                f\"url-watches.json remains intact, safe to retry.\"\n            )\n\n        logger.critical(f\"Phase 2 complete: Verified {watch_count} watch files\")\n\n        # Phase 3: Create new settings file\n        logger.critical(\"Phase 3/4: Creating changedetection.json...\")\n\n        try:\n            self._save_settings()\n        except Exception as e:\n            logger.error(f\"Failed to create changedetection.json: {e}\")\n            raise Exception(\n                f\"Migration failed: Could not create changedetection.json. \"\n                f\"url-watches.json remains intact, safe to retry. Error: {e}\"\n            )\n\n        # Phase 4: Verify settings file exists\n        logger.critical(\"Phase 4/4: Verifying changedetection.json exists...\")\n        changedetection_json_new_schema=os.path.join(self.datastore_path, \"changedetection.json\")\n        if not os.path.isfile(changedetection_json_new_schema):\n            import sys\n            logger.critical(\"Migration failed, changedetection.json not found after update ran!\")\n            sys.exit(1)\n\n\n        logger.critical(\"Phase 4 complete: Verified changedetection.json exists\")\n\n        # Success! Now reload from new format\n        logger.critical(\"Reloading datastore from new format...\")\n        # write it to disk, it will be saved without ['watching'] in the JSON db because we find it from disk glob\n        self._save_settings()\n        logger.success(\"Datastore reloaded from new format successfully\")\n        logger.critical(\"=\" * 80)\n        logger.critical(\"MIGRATION COMPLETED SUCCESSFULLY!\")\n        logger.critical(\"=\" * 80)\n        logger.info(\"\")\n        logger.info(\"New format:\")\n        logger.info(f\"  - {watch_count} individual watch.json files created\")\n        logger.info(f\"  - changedetection.json created (settings only)\")\n        logger.info(\"\")\n        logger.info(\"Rollback safety:\")\n        logger.info(\"  - url-watches.json preserved for rollback\")\n        logger.info(\"  - To rollback: downgrade to previous version and restart\")\n        logger.info(\"  - No manual file operations needed\")\n        logger.info(\"\")\n        logger.info(\"Optional cleanup (after testing new version):\")\n        logger.info(f\"  - rm {os.path.join(self.datastore_path, 'url-watches.json')}\")\n        logger.info(\"\")\n\n    def update_26(self):\n        self.migrate_legacy_db_format()\n\n    # Re-run tag to JSON migration\n    def update_29(self):\n\n        \"\"\"\n        Migrate tags to individual tag.json files.\n\n        Tags are currently saved only in changedetection.json (settings).\n        This migration ALSO saves them to individual {uuid}/tag.json files,\n        similar to how watches are stored (dual storage).\n\n        Benefits:\n        - Allows atomic tag updates without rewriting entire settings\n        - Enables independent tag versioning/backup\n        - Maintains backwards compatibility (tags stay in settings too)\n        \"\"\"\n        logger.critical(\"=\" * 80)\n        logger.critical(\"Running migration: Individual tag persistence (update_28)\")\n        logger.critical(\"Creating individual tag.json files\")\n        logger.critical(\"=\" * 80)\n\n        tags = self.data['settings']['application'].get('tags', {})\n        tag_count = len(tags)\n\n        if tag_count == 0:\n            logger.info(\"No tags found, skipping migration\")\n            return\n\n        logger.info(f\"Migrating {tag_count} tags to individual tag.json files...\")\n\n        saved_count = 0\n        failed_count = 0\n\n        for uuid, tag_data in tags.items():\n            if os.path.isfile(os.path.join(self.datastore_path, uuid, \"tag.json\")):\n                logger.debug(f\"Tag {uuid} tag.json exists, skipping\")\n                continue\n            try:\n                tag_data.commit()\n                saved_count += 1\n                if saved_count % 10 == 0:\n                    logger.info(f\"  Progress: {saved_count}/{tag_count} tags migrated...\")\n\n            except Exception as e:\n                logger.error(f\"Failed to save tag {uuid} ({tag_data.get('title', 'unknown')}): {e}\")\n                failed_count += 1\n\n        if failed_count > 0:\n            logger.warning(f\"Migration complete: {saved_count} tags saved, {failed_count} tags FAILED\")\n        else:\n            logger.success(f\"Migration complete: {saved_count} tags saved to individual tag.json files\")\n\n        # Tags remain in settings for backwards compatibility AND easy access\n        # On next load, _load_tags() will read from tag.json files and merge with settings\n        logger.info(\"Tags saved to both settings AND individual tag.json files\")\n        logger.info(\"Future tag edits will update both locations (dual storage)\")\n        logger.critical(\"=\" * 80)\n\n        # write it to disk, it will be saved without ['tags'] in the JSON db because we find it from disk glob\n        # (left this out by accident in previous update, added tags={} in the changedetection.json save_to_disk)\n        self._save_settings()\n\n    def update_30(self):\n        \"\"\"Migrate restock_settings out of watch.json into restock_diff.json processor config file.\n\n        Previously, restock_diff processor settings (in_stock_processing, follow_price_changes, etc.)\n        were stored directly in the watch dict (watch.json). They now belong in a separate per-watch\n        processor config file (restock_diff.json) consistent with the processor_config_* API system.\n\n        For tags: restock_settings key is renamed to processor_config_restock_diff in the tag dict,\n        matching what the API writes when updating a tag.\n\n        Safe to re-run: skips watches that already have a restock_diff.json, skips tags that already\n        have processor_config_restock_diff set.\n        \"\"\"\n        import json\n\n        # --- Watches ---\n        for uuid, watch in self.data['watching'].items():\n            if watch.get('processor') != 'restock_diff':\n                continue\n            restock_settings = watch.get('restock_settings')\n            if not restock_settings:\n                continue\n\n            data_dir = watch.data_dir\n            if data_dir:\n                watch.ensure_data_dir_exists()\n                filepath = os.path.join(data_dir, 'restock_diff.json')\n                if not os.path.isfile(filepath):\n                    with open(filepath, 'w', encoding='utf-8') as f:\n                        json.dump({'restock_diff': restock_settings}, f, indent=2)\n                    logger.info(f\"update_30: migrated restock_settings → {filepath}\")\n\n            del self.data['watching'][uuid]['restock_settings']\n            watch.commit()\n\n        # --- Tags ---\n        for tag_uuid, tag in self.data['settings']['application']['tags'].items():\n            restock_settings = tag.get('restock_settings')\n            if not restock_settings or tag.get('processor_config_restock_diff'):\n                continue\n            tag['processor_config_restock_diff'] = restock_settings\n            del tag['restock_settings']\n            tag.commit()\n            logger.info(f\"update_30: migrated tag {tag_uuid} restock_settings → processor_config_restock_diff\")\n\n"
  },
  {
    "path": "changedetectionio/strtobool.py",
    "content": "# Because strtobool was removed in python 3.12 distutils\n\n_MAP = {\n    'y': True,\n    'yes': True,\n    't': True,\n    'true': True,\n    'on': True,\n    '1': True,\n    'n': False,\n    'no': False,\n    'f': False,\n    'false': False,\n    'off': False,\n    '0': False\n}\n\n\ndef strtobool(value):\n    if not value:\n        return False\n    try:\n        return _MAP[str(value).lower()]\n    except KeyError:\n        raise ValueError('\"{}\" is not a valid bool value'.format(value))\n"
  },
  {
    "path": "changedetectionio/templates/IMPORTANT.md",
    "content": "# Important notes about templates\n\nTemplate names should always end in \".html\", \".htm\", \".xml\", \".xhtml\", \".svg\", even the `import`'ed templates.\n\nJinja2's `def select_jinja_autoescape(self, filename: str) -> bool:` will check the filename extension and enable autoescaping\n\n"
  },
  {
    "path": "changedetectionio/templates/_common_fields.html",
    "content": "\n{% from '_helpers.html' import render_field %}\n\n{% macro show_token_placeholders(extra_notification_token_placeholder_info, suffix=\"\") %}\n\n\n    <div class=\"pure-controls\">\n            <span class=\"pure-form-message-inline\">\n        {{ _('Body for all notifications — You can use') }} <a target=\"newwindow\" href=\"https://jinja.palletsprojects.com/en/3.0.x/templates/\">Jinja2</a> {{ _('templating in the notification title, body and URL, and tokens from below.') }}\n    </span><br>\n        <div data-target=\"#notification-tokens-info{{ suffix }}\" class=\"toggle-show pure-button button-tag button-xsmall\">{{ _('Show token/placeholders') }}\n        </div>\n    </div>\n    <div class=\"pure-controls\" style=\"display: none;\" id=\"notification-tokens-info{{ suffix }}\">\n        <table class=\"pure-table\" id=\"token-table\">\n            <thead>\n            <tr>\n                <th>{{ _('Token') }}</th>\n                <th>{{ _('Description') }}</th>\n            </tr>\n            </thead>\n            <tbody>\n            <tr>\n                <td><code>{{ '{{base_url}}' }}</code></td>\n                <td>{{ _('The URL of the changedetection.io instance you are running.') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{watch_url}}' }}</code></td>\n                <td>{{ _('The URL being watched.') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{watch_uuid}}' }}</code></td>\n                <td>{{ _('The UUID of the watch.') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{watch_title}}' }}</code></td>\n                <td>{{ _('The page title of the watch, uses <title> if not set, falls back to URL') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{watch_tag}}' }}</code></td>\n                <td>{{ _('The watch group / tag') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{preview_url}}' }}</code></td>\n                <td>{{ _('The URL of the preview page generated by changedetection.io.') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{change_datetime}}' }}</code></td>\n                <td>{{ _('Date/time of the change, accepts format=, change_datetime(format=\\'%A\\')\\', default is \\'%Y-%m-%d %H:%M:%S %Z\\'') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_url}}' }}</code></td>\n                <td>{{ _('The URL of the diff output for the watch.') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_url}}' }}</code></td>\n                <td>{{ _('The URL of the diff output for the watch.') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff}}' }}</code></td>\n                <td>{{ _('The diff output - only changes, additions, and removals') }}<br>\n                    <small>\n                        {{ _('All diff variants accept') }} <code>lines=</code>, <code>context=</code>, <code>word_diff=</code>, <code>ignore_junk=</code> {{ _('args, e.g.') }}\n                        <code>{{ '{{diff(lines=10)}}' }}</code>, <code>{{ '{{diff_added(lines=5, context=2)}}' }}</code>\n                    </small>\n                </td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_clean}}' }}</code></td>\n                <td>{{ _('The diff output - only changes, additions, and removals —') }} <i>{{ _('Without (added) prefix or colors') }}</i>\n                </td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_added}}' }}</code></td>\n                <td>{{ _('The diff output - only changes and additions') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_added_clean}}' }}</code></td>\n                <td>{{ _('The diff output - only changes and additions —') }} <i>{{ _('Without (added) prefix or colors') }}</i></td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_removed}}' }}</code></td>\n                <td>{{ _('The diff output - only changes and removals') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_removed_clean}}' }}</code></td>\n                <td>{{ _('The diff output - only changes and removals —') }} <i>{{ _('Without (added) prefix or colors') }}</i></td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_full}}' }}</code></td>\n                <td>{{ _('The diff output - full difference output') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_full_clean}}' }}</code></td>\n                <td>{{ _('The diff output - full difference output —') }} <i>{{ _('Without (added) prefix or colors') }}</i></td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{diff_patch}}' }}</code></td>\n                <td>{{ _('The diff output - patch in unified format') }}</td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{current_snapshot}}' }}</code></td>\n                <td>{{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }}\n                </td>\n            </tr>\n            <tr>\n                <td><code>{{ '{{triggered_text}}' }}</code></td>\n                <td>{{ _('Text that tripped the trigger from filters') }}</td>\n\n                {% if extra_notification_token_placeholder_info %}\n                    {% for token in extra_notification_token_placeholder_info %}\n                        <tr>\n                            <td><code>{{ '{{' }}{{ token[0] }}{{ '}}' }}</code></td>\n                            <td>{{ token[1] }}</td>\n                        </tr>\n                    {% endfor %}\n                {% endif %}\n            </tbody>\n        </table>\n\n        <span class=\"pure-form-message-inline\">\n        {{ _('Warning: Contents of') }} <code>{{ '{{diff}}' }}</code>, <code>{{ '{{diff_removed}}' }}</code>, {{ _('and') }} <code>{{ '{{diff_added}}' }}</code> {{ _('depend on how the difference algorithm perceives the change.') }} <br>\n        {{ _('For example, an addition or removal could be perceived as a change in some cases.') }} <a target=\"newwindow\" href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Using-the-%7B%7Bdiff%7D%7D,-%7B%7Bdiff_added%7D%7D,-and-%7B%7Bdiff_removed%7D%7D-notification-tokens\">{{ _('More Here') }}</a> <br>\n        </span>\n    </div>\n{%  endmacro %}\n\n{% macro render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) %}\n                        <div class=\"pure-control-group\">\n                            {{ render_field(form.notification_urls, rows=5, placeholder=\"Examples:\n    Gitter - gitter://token/room\n    Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail\n    AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo\n    SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com\",\n    class=\"notification-urls\" )\n                            }}\n                            <div class=\"pure-form-message-inline\">\n                                <p>\n                                <strong>{{ _('Tip:') }}</strong> {{ _('Use') }} <a target=\"newwindow\" href=\"https://github.com/caronc/apprise\">{{ _('AppRise Notification URLs') }}</a> {{ _('for notification to just about any service!') }} <i><a target=\"newwindow\" href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes\">{{ _('Please read the notification services wiki here for important configuration notes') }}</a></i>.<br>\n</p>\n                                <div data-target=\"#advanced-help-notifications\" class=\"toggle-show pure-button button-tag button-xsmall\">{{ _('Show advanced help and tips') }}</div>\n                                <ul style=\"display: none\" id=\"advanced-help-notifications\">\n                                <li><code><a target=\"newwindow\" href=\"https://github.com/caronc/apprise/wiki/Notify_discord\">discord://</a></code> {{ _('(or') }} <code>https://discord.com/api/webhooks...</code>)) {{ _('only supports a maximum') }} <strong>{{ _('2,000 characters') }}</strong> {{ _('of notification text, including the title.') }}</li>\n                                <li><code><a target=\"newwindow\" href=\"https://github.com/caronc/apprise/wiki/Notify_telegram\">tgram://</a></code> {{ _('bots can\\'t send messages to other bots, so you should specify chat ID of non-bot user.') }}</li>\n                                <li><code><a target=\"newwindow\" href=\"https://github.com/caronc/apprise/wiki/Notify_telegram\">tgram://</a></code> {{ _('only supports very limited HTML and can fail when extra tags are sent,') }} <a href=\"https://core.telegram.org/bots/api#html-style\">{{ _('read more here') }}</a> {{ _('(or use plaintext/markdown format)') }}</li>\n                                <li><code>gets://</code>, <code>posts://</code>, <code>puts://</code>, <code>deletes://</code> {{ _('for direct API calls (or omit the') }} \"<code>s</code>\" {{ _('for non-SSL ie') }} <code>get://</code>) <a href=\"https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes#postposts\">{{ _('more help here') }}</a></li>\n                                  <li>{{ _('Accepts the') }} <code>{{ '{{token}}' }}</code> {{ _('placeholders listed below') }}</li>\n                              </ul>\n                            </div>\n                            <div class=\"notifications-wrapper\">\n                              <a id=\"send-test-notification\" class=\"pure-button button-secondary button-xsmall\" >{{ _('Send test notification') }}</a> <div class=\"spinner\"  style=\"display: none;\"></div>\n                            {% if emailprefix %}\n                              <a id=\"add-email-helper\" class=\"pure-button button-secondary button-xsmall\" >{{ _('Add email') }} <img style=\"height: 1em; display: inline-block\" src=\"{{url_for('static_content', group='images', filename='email.svg')}}\" alt=\"{{ _('Add an email address') }}\"> </a>\n                            {% endif %}\n                              <a href=\"{{url_for('settings.notification_logs')}}\" class=\"pure-button button-secondary button-xsmall\" >{{ _('Notification debug logs') }}</a>\n                              <br>\n                                <div id=\"notification-test-log\" style=\"display: none;\"><span class=\"pure-form-message-inline\">{{ _('Processing..') }}</span></div>\n                            </div>\n                        </div>\n\n                        <div class=\"pure-control-group grey-form-border\">\n                            <div class=\"pure-control-group\">\n                                {{ render_field(form.notification_title, class=\"m-d notification-title\", placeholder=settings_application['notification_title']) }}\n                                <span class=\"pure-form-message-inline\">{{ _('Title for all notifications') }}</span>\n                            </div>\n                            <div class=\"pure-control-group\">\n                                {{ render_field(form.notification_body , rows=5, class=\"notification-body\", placeholder=settings_application['notification_body']) }}\n                                {{ show_token_placeholders(extra_notification_token_placeholder_info=extra_notification_token_placeholder_info) }}\n                                <div class=\"pure-form-message-inline\">\n                                    <ul>\n                                    <li><span class=\"pure-form-message-inline\">\n                                        {{ _('For JSON payloads, use') }} <strong>|tojson</strong> {{ _('without quotes for automatic escaping, for example -') }} <code>{ \"name\": {{ '{{ watch_title|tojson }}' }} }</code>\n                                    </span></li>\n                                    <li><span class=\"pure-form-message-inline\">\n                                        {{ _('URL encoding, use') }} <strong>|urlencode</strong>, {{ _('for example -') }} <code>gets://hook-website.com/test.php?title={{ '{{ watch_title|urlencode }}' }}</code>\n                                    </span></li>\n                                    <li><span class=\"pure-form-message-inline\">\n                                        {{ _('Regular-expression replace, use') }} <strong>|regex_replace</strong>, {{ _('for example -') }}   <code>{{ \"{{ \\\"hello world 123\\\" | regex_replace('[0-9]+', 'no-more-numbers') }}\" }}</code>\n                                    </span></li>\n                                    <li><span class=\"pure-form-message-inline\">\n                                        {{ _('For a complete reference of all Jinja2 built-in filters, users can refer to the') }} <a href=\"https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters\">https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters</a>\n                                    </span></li>\n                                    </ul>\n                                    <br>\n                                </div>\n                            </div>\n                            <div class=\"\">\n                                {{ render_field(form.notification_format , class=\"notification-format\") }}\n                                <span class=\"pure-form-message-inline\">{{ _('Format for all notifications') }}</span>\n                            </div>\n                        </div>\n{% endmacro %}\n"
  },
  {
    "path": "changedetectionio/templates/_helpers.html",
    "content": "{% macro render_field(field) %}\n    <div {% if field.errors or field.top_errors %} class=\"error\" {% endif %}>{{ field.label }}</div>\n    <div {% if field.errors or field.top_errors %} class=\"error\" {% endif %}>{{ field(**kwargs)|safe }}\n        {% if field.top_errors %}\n            top\n            <ul class=\"errors top-errors\">\n                {% for error in field.top_errors %}\n                    <li>{{ error }}</li>\n                {% endfor %}\n            </ul>\n        {% endif %}\n        {% if field.errors %}\n            <ul class=errors>\n                {% if field.errors is mapping and 'form' in field.errors %}\n                    {#  and subfield form errors, such as used in RequiredFormField() for TimeBetweenCheckForm sub form #}\n                    {% set errors = field.errors['form'] %}\n                    {% for error in errors %}\n                        <li>{{ error }}</li>\n                    {% endfor %}\n                {% elif field.type == 'FieldList' %}\n                    {# Handle FieldList of FormFields - errors is a list of dicts, one per entry #}\n                    {% for idx, entry_errors in field.errors|enumerate %}\n                        {% if entry_errors is mapping and entry_errors %}\n                            {# Only show entries that have actual errors #}\n                            <li><strong>{{ _('Entry') }} {{ idx + 1 }}:</strong>\n                                <ul>\n                                    {% for field_name, messages in entry_errors.items() %}\n                                        {% for message in messages %}\n                                            <li>{{ field_name }}: {{ message }}</li>\n                                        {% endfor %}\n                                    {% endfor %}\n                                </ul>\n                            </li>\n                        {% endif %}\n                    {% endfor %}\n                {% else %}\n                    {#  regular list of errors with this field #}\n                    {% for error in field.errors %}\n                        <li>{{ error }}</li>\n                    {% endfor %}\n                {% endif %}\n            </ul>\n        {% endif %}\n    </div>\n{% endmacro %}\n\n{% macro render_checkbox_field(field) %}\n  <div class=\"checkbox {% if field.errors %} error {% endif %}\">\n  {{ field(**kwargs)|safe }} <label for=\"{{ field.id }}\">{{ field.label.text | string | forceescape }}</label>\n  {% if field.errors %}\n    <ul class=errors>\n    {% for error in field.errors %}\n      <li>{{ error }}</li>\n    {% endfor %}\n    </ul>\n  {% endif %}\n  </div>\n{% endmacro %}\n\n{% macro render_ternary_field(field, BooleanField=false) %}\n  {% if BooleanField %}\n    {% set dummy = field.__setattr__('boolean_mode', true) %}\n  {% endif %}\n  <div class=\"ternary-field {% if field.errors %} error {% endif %}\">\n    <div class=\"ternary-field-label\"><label for=\"{{ field.id }}\">{{ field.label.text | string | forceescape }}</label></div>\n    <div class=\"ternary-field-widget\">{{ field(**kwargs)|safe }}</div>\n    {% if field.errors %}\n      <ul class=errors>\n      {% for error in field.errors %}\n        <li>{{ error }}</li>\n      {% endfor %}\n      </ul>\n    {% endif %}\n  </div>\n{% endmacro %}\n\n\n{% macro render_simple_field(field) %}\n  <span class=\"label {% if field.errors %}error{% endif %}\"><label for=\"{{ field.id }}\">{{ field.label.text | string | forceescape }}</label></span>\n  <span {% if field.errors %} class=\"error\" {% endif %}>{{ field(**kwargs)|safe }}\n  {% if field.errors %}\n    <ul class=errors>\n    {% for error in field.errors %}\n      <li>{{ error }}</li>\n    {% endfor %}\n    </ul>\n  {% endif %}\n  </span>\n{% endmacro %}\n\n\n{% macro render_nolabel_field(field) %}\n    <span>\n    {{ field(**kwargs)|safe }}\n        {% if field.errors %}\n            <span class=\"error\">\n      {% if field.errors %}\n          <ul class=errors>\n        {% for error in field.errors %}\n            <li>{{ error }}</li>\n        {% endfor %}\n        </ul>\n      {% endif %}\n      </span>\n        {% endif %}\n    </span>\n{% endmacro %}\n\n\n{% macro render_button(field) %}\n  {{ field(**kwargs)|safe }}\n{% endmacro %}\n\n{% macro render_fieldlist_with_inline_errors(fieldlist) %}\n  {# Specialized macro for FieldList(FormField(...)) that renders errors inline with each field #}\n  <div {% if fieldlist.errors %} class=\"error\" {% endif %}>{{ _(fieldlist.label.text | string) }}</div>\n  <div {% if fieldlist.errors %} class=\"error\" {% endif %}>\n    <ul id=\"{{ fieldlist.id }}\">\n      {% for entry in fieldlist %}\n        <li {% if entry.errors %} class=\"error\" {% endif %}>\n          <label for=\"{{ entry.id }}\" {% if entry.errors %} class=\"error\" {% endif %}>{{ _(fieldlist.label.text | string) }}-{{ loop.index0 }}</label>\n          <table id=\"{{ entry.id }}\" {% if entry.errors %} class=\"error\" {% endif %}>\n            <tbody>\n              {% for subfield in entry %}\n                <tr {% if subfield.errors %} class=\"error\" {% endif %}>\n                  <th {% if subfield.errors %} class=\"error\" {% endif %}><label for=\"{{ subfield.id }}\" {% if subfield.errors %} class=\"error\" {% endif %}>{{ subfield.label.text | string }}</label></th>\n                  <td {% if subfield.errors %} class=\"error\" {% endif %}>\n                    {{ subfield(**kwargs)|safe }}\n                    {% if subfield.errors %}\n                      <ul class=\"errors\">\n                        {% for error in subfield.errors %}\n                          <li class=\"error\">{{ error }}</li>\n                        {% endfor %}\n                      </ul>\n                    {% endif %}\n                  </td>\n                </tr>\n              {% endfor %}\n            </tbody>\n          </table>\n        </li>\n      {% endfor %}\n    </ul>\n  </div>\n{% endmacro %}\n\n{% macro render_conditions_fieldlist_of_formfields_as_table(fieldlist, table_id=\"rulesTable\") %}\n  <div class=\"fieldlist_formfields\" id=\"{{ table_id }}\">\n    <div class=\"fieldlist-header\">\n      {% for subfield in fieldlist[0] %}\n        <div class=\"fieldlist-header-cell\">{{ subfield.label.text | string }}</div>\n      {% endfor %}\n      <div class=\"fieldlist-header-cell\">{{ _('Actions') }}</div>\n    </div>\n    <div class=\"fieldlist-body\">\n      {% for form_row in fieldlist %}\n        <div class=\"fieldlist-row {% if form_row.errors %}error-row{% endif %}\">\n          {% for subfield in form_row %}\n            <div class=\"fieldlist-cell\">\n\n              {{ subfield()|safe }}\n              {% if subfield.errors %}\n                <ul class=\"errors\">\n                  {% for error in subfield.errors %}\n                    <li class=\"error\">{{ error }}</li>\n                  {% endfor %}\n                </ul>\n              {% endif %}\n            </div>\n          {% endfor %}\n          <div class=\"fieldlist-cell fieldlist-actions\">\n            <button type=\"button\" class=\"addRuleRow\" title=\"{{ _('Add a row/rule after') }}\">+</button>\n            <button type=\"button\" class=\"removeRuleRow\" title=\"{{ _('Remove this row/rule') }}\">-</button>\n            <button type=\"button\" class=\"verifyRuleRow\" title=\"{{ _('Verify this rule against current snapshot') }}\">✓</button>\n          </div>\n        </div>\n      {% endfor %}\n    </div>\n  </div>\n{% endmacro %}\n\n\n{% macro playwright_warning() %}\n    <p><strong>{{ _('Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.') }}</strong> {{ _('Alternatively try our') }} <a href=\"https://changedetection.io\">{{ _('very affordable subscription based service which has all this setup for you') }}</a>.</p>\n    <p>{{ _('You may need to') }} <a href=\"https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31\">{{ _('Enable playwright environment variable') }}</a> {{ _('and uncomment the') }} <strong>sockpuppetbrowser</strong> {{ _('in the') }} <a href=\"https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml\">docker-compose.yml</a> {{ _('file') }}.</p>\n    <br>\n{% endmacro %}\n\n{% macro render_time_schedule_form(form, available_timezones, timezone_default_config) %}\n    <style>\n    .day-schedule *, .day-schedule select {\n        display: inline-block;\n    }\n\n    .day-schedule label[for*=\"time_schedule_limit-\"][for$=\"-enabled\"] {\n        min-width: 6rem;\n        font-weight: bold;\n    }\n    .day-schedule label {\n        font-weight: normal;\n    }\n\n    .day-schedule table label {\n        padding-left: 0.5rem;\n        padding-right: 0.5rem;\n    }\n    #timespan-warning, input[id*='time_schedule_limit-timezone'].error {\n        color: #ff0000;\n    }\n    .day-schedule.warning table {\n        background-color: #ffbbc2;\n    }\n    ul#day-wrapper {\n        list-style: none;\n    }\n    #timezone-info > * {\n        display: inline-block;\n    }\n\n    #scheduler-icon-label {\n        background-position: left center;\n        background-repeat: no-repeat;\n        background-size: contain;\n        display: inline-block;\n        vertical-align: middle;\n        padding-left: 50px;\n        background-image: url({{ url_for('static_content', group='images', filename='schedule.svg') }});\n    }\n    #timespan-warning {\n        display: none;\n    }\n    </style>\n    <br>\n\n    {% if timezone_default_config %}\n    <div>\n        <span id=\"scheduler-icon-label\" style=\"\">\n            {{ render_checkbox_field(form.time_schedule_limit.enabled) }}\n            <div class=\"pure-form-message-inline\">\n                {{ _('Set a hourly/week day schedule') }}\n            </div>\n        </span>\n\n    </div>\n    <br>\n    <div id=\"schedule-day-limits-wrapper\">\n        <label>{{ _('Schedule time limits') }}</label><a data-template=\"business-hours\"\n                                              class=\"set-schedule pure-button button-secondary button-xsmall\">{{ _('Business hours') }}</a>\n        <a data-template=\"weekend\" class=\"set-schedule pure-button button-secondary button-xsmall\">{{ _('Weekends') }}</a>\n        <a data-template=\"reset\" class=\"set-schedule pure-button button-xsmall\">{{ _('Reset') }}</a><br>\n        <br>\n\n        <ul id=\"day-wrapper\">\n            {% for day in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] %}\n                <li class=\"day-schedule\" id=\"schedule-{{ day }}\">\n                    {{ render_nolabel_field(form.time_schedule_limit[day]) }}\n                </li>\n            {% endfor %}\n            <li id=\"timespan-warning\">{{ _(\"Warning, one or more of your 'days' has a duration that would extend into the next day.\") }}<br>\n            {{ _('This could have unintended consequences.') }}</li>\n            <li id=\"timezone-info\">\n                {{ render_field(form.time_schedule_limit.timezone, placeholder=timezone_default_config) }} <span id=\"local-time-in-tz\"></span>\n                <datalist id=\"timezones\" style=\"display: none;\">\n                    {%- for timezone in available_timezones -%}<option value=\"{{ timezone }}\">{{ timezone }}</option>{%- endfor -%}\n                </datalist>\n            </li>\n        </ul>\n    <br>\n        <span class=\"pure-form-message-inline\">\n         <a href=\"https://changedetection.io/tutorial/checking-web-pages-changes-according-schedule\">{{ _('More help and examples about using the scheduler') }}</a>\n        </span>\n    </div>\n    {% else %}\n        <span class=\"pure-form-message-inline\">\n            {{ _('Want to use a time schedule?') }} <a href=\"{{url_for('settings.settings_page')}}#timedate\">{{ _('First confirm/save your Time Zone Settings') }}</a>\n        </span>\n        <br>\n    {% endif %}\n\n{% endmacro %}\n\n{% macro highlight_trigger_ignored_explainer() %}\n                <p>\n                    <span title=\"{{ _('Triggers a change if this text appears, AND something changed in the document.') }}\" style=\"background-color: var(--highlight-trigger-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;\">{{ _('Triggered text') }}</span>\n                    <span title=\"{{ _('Ignored for calculating changes, but still shown.') }}\" style=\"background-color: var(--highlight-ignored-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;\">{{ _('Ignored text') }}</span>\n                    <span title=\"{{ _('No change-detection will occur because this text exists.') }}\" style=\"background-color: var(--highlight-blocked-text-bg-color); color: #fff; padding: 4px; border-radius: 2px; margin-right: 4px;\">{{ _('Blocked text') }}</span>\n                </p>\n{% endmacro %}"
  },
  {
    "path": "changedetectionio/templates/base.html",
    "content": "<!DOCTYPE html>\n<html lang=\"{{ get_locale()|replace('_', '-') }}\" data-darkmode=\"{{ get_darkmode_state() }}\">\n\n  <head>\n    <meta charset=\"utf-8\" >\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" >\n    <meta name=\"description\" content=\"Self hosted website change detection.\" >\n    <meta name=\"robots\" content=\"noindex\">\n    <title>Change Detection{{extra_title}}</title>\n    {% if app_rss_token %}\n        <link rel=\"alternate\" type=\"application/rss+xml\" title=\"Changedetection.io » Feed{% if active_tag_uuid %}- {{active_tag.title}}{% endif %}\" href=\"{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token, _external=True )}}\" >\n\n        {% if rss_uuid_feed %}\n        <link rel=\"alternate\" type=\"application/rss+xml\" title=\"Feed » {{ rss_uuid_feed['label'] }}\" href=\"{{ rss_uuid_feed['url'] }}\" >\n\n        {%- endif -%}\n    {%- endif -%}\n    <link rel=\"stylesheet\" href=\"{{url_for('static_content', group='styles', filename='pure-min.css')}}\" >\n    <link rel=\"stylesheet\" href=\"{{url_for('static_content', group='styles', filename='flag-icons.min.css')}}\" >\n    <link rel=\"stylesheet\" href=\"{{url_for('static_content', group='styles', filename='styles.css')}}?v={{ get_css_version() }}\" >\n    {% if extra_stylesheets %}\n      {% for m in extra_stylesheets %}\n        <link rel=\"stylesheet\" href=\"{{ m }}?ver={{ get_css_version() }}\" >\n      {% endfor %}\n    {% endif %}\n\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"{{url_for('static_content', group='favicons', filename='apple-touch-icon.png')}}\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"{{url_for('static_content', group='favicons', filename='favicon-32x32.png')}}\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"{{url_for('static_content', group='favicons', filename='favicon-16x16.png')}}\">\n    <link rel=\"manifest\" href=\"{{url_for('static_content', group='favicons', filename='site.webmanifest')}}\" crossorigin=\"use-credentials\">\n    <link rel=\"mask-icon\" href=\"{{url_for('static_content', group='favicons', filename='safari-pinned-tab.svg')}}\" color=\"#5bbad5\">\n    <link rel=\"shortcut icon\" href=\"{{url_for('static_content', group='favicons', filename='favicon.ico')}}\">\n    <meta name=\"msapplication-TileColor\" content=\"#da532c\">\n    <meta name=\"msapplication-config\" content=\"favicons/browserconfig.xml\">\n    <meta name=\"theme-color\" content=\"#ffffff\">\n    <script>\n        const csrftoken=\"{{ csrf_token() }}\";\n        const socketio_url=\"{{ get_socketio_path() }}/socket.io\";\n        const is_authenticated = {% if current_user.is_authenticated or not has_password %}true{% else %}false{% endif %};\n    </script>\n    <script src=\"{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}\"></script>\n    <script src=\"{{url_for('static_content', group='js', filename='csrf.js')}}\" defer></script>\n    <script src=\"{{url_for('static_content', group='js', filename='feather-icons.min.js')}}\" defer></script>\n    {% if socket_io_enabled %}\n    <script src=\"{{url_for('static_content', group='js', filename='socket.io.min.js')}}\"></script>\n    <script src=\"{{url_for('static_content', group='js', filename='realtime.js')}}\" defer></script>\n    {% endif %}\n  </head>\n\n  <body class=\"{{extra_classes}}\">\n    <div class=\"header\">\n    <div {% if pure_menu_fixed != False %}class=\"pure-menu-fixed\"{% endif %} style=\"width: 100%;\">\n      <div class=\"home-menu pure-menu pure-menu-horizontal\" id=\"nav-menu\">\n\n        {% if has_password and not current_user.is_authenticated %}\n          <a id=\"cdio-logo\" class=\"pure-menu-heading\" href=\"https://changedetection.io\" rel=\"noopener\">\n            <strong>Change</strong>Detection.io</a>\n        {% else %}\n          <a  id=\"cdio-logo\" class=\"pure-menu-heading\" href=\"{{url_for('watchlist.index')}}\">\n            <strong>Change</strong>Detection.io</a>\n        {% endif %}\n        {% if current_diff_url and is_safe_valid_url(current_diff_url) %}\n          <a class=\"current-diff-url\" href=\"{{ current_diff_url }}\">\n            <span style=\"max-width: 30%; overflow: hidden\">{{ current_diff_url }}</span></a>\n        {% else %}\n          {% if new_version_available and not(has_password and not current_user.is_authenticated) %}\n            <span id=\"new-version-text\" class=\"pure-menu-heading\">\n              <a href=\"https://changedetection.io\">A new version is available</a>\n            </span>\n          {% endif %}\n        {% endif %}\n\n        <ul class=\"pure-menu-list\" id=\"top-right-menu\">\n          <!-- Collapsible menu items (hidden on mobile, shown in drawer) -->\n          {% include \"menu.html\" %}\n\n          {% if current_user.is_authenticated or not has_password %}\n            {% if not current_diff_url %}\n              <li class=\"pure-menu-item  menu-collapsible\">\n                <button class=\"toggle-button\" id=\"open-search-modal\" type=\"button\" title=\"{{ _('Search, or Use Alt+S Key') }}\">\n                  {% include \"svgs/search-icon.svg\" %}\n                </button>\n              </li>\n\n            {% endif %}\n          {% endif %}\n\n          <li class=\"pure-menu-item\" id=\"heart-us\">\n                <svg\n                   fill=\"#ff0000\"\n                   class=\"bi bi-heart\"\n                   preserveAspectRatio=\"xMidYMid meet\"\n                   viewBox=\"0 0 16.9 16.1\"\n                   id=\"svg-heart\"\n                   xmlns=\"http://www.w3.org/2000/svg\"\n                   >\n                  <path id=\"heartpath\" d=\"M 5.338316,0.50302766 C 0.71136983,0.50647126 -3.9576371,7.2707777 8.5004254,15.503028 23.833425,5.3700277 13.220206,-2.5384409 8.6762066,1.6475589 c -0.060791,0.054322 -0.11943,0.1110064 -0.1757812,0.1699219 -0.057,-0.059 -0.1157813,-0.116875 -0.1757812,-0.171875 C 7.4724566,0.86129334 6.4060729,0.50223298 5.338316,0.50302766 Z\" style=\"fill:var(--color-background);fill-opacity:1;stroke:#ff0000;stroke-opacity:1\" />\n                </svg>\n          </li>\n          <!-- Hamburger menu button (mobile only) -->\n          <li class=\"pure-menu-item\">\n            <button class=\"hamburger-menu\" id=\"hamburger-toggle\" aria-label=\"Toggle menu\">\n              <div class=\"hamburger-icon\">\n                <span></span>\n                <span></span>\n                <span></span>\n              </div>\n            </button>\n          </li>\n        </ul>\n      </div>\n\n      <!-- Mobile menu drawer -->\n      <div class=\"mobile-menu-overlay\" id=\"mobile-menu-overlay\"></div>\n      <div class=\"mobile-menu-drawer\" id=\"mobile-menu-drawer\">\n        <ul class=\"mobile-menu-items\">\n          {% include \"menu.html\" %}\n            <li class=\"pure-menu-item menu-collapsible\">\n                {%- if right_sticky -%}<div>{{ right_sticky }}</div>{%- endif -%}\n                <a href=\"https://changedetection.io/?ref={{ guid }}\">Let us host your instance!</a><br>\n            </li>\n        </ul>\n      </div>\n      <div id=\"pure-menu-horizontal-spinner\"></div>\n      </div>\n\n    </div>\n    {% if hosted_sticky %}\n      <div class=\"sticky-tab\" id=\"hosted-sticky\">\n        <a href=\"https://changedetection.io/?ref={{guid}}\">Let us host your instance!</a>\n      </div>\n    {% endif %}\n    {% if left_sticky %}\n      <div class=\"sticky-tab\" id=\"left-sticky\">\n      </div>\n    {% endif %}\n    {% if right_sticky %}\n      <div class=\"sticky-tab\" id=\"right-sticky\">{{ right_sticky }}</div>\n    {% endif %}\n    <section class=\"content\">\n        <div id=\"overlay\">\n            <div class=\"content\">\n                <h4>Try our Chrome extension</h4>\n                <p>\n                    <a id=\"chrome-extension-link\"\n                       title=\"Chrome Extension - Web Page Change Detection with changedetection.io!\"\n                       href=\"https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop\">\n                        <img alt=\"Chrome store icon\" src=\"{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}\">\n                        Chrome Webstore\n                    </a>\n                </p>\n                Easily add the current web-page from your browser directly into your changedetection.io tool, more great features coming soon!\n                <h4>Changedetection.io needs your support!</h4>\n                <p>\n                    You can help us by supporting changedetection.io on these platforms;\n                </p>\n                <p>\n                <ul>\n                    <li>\n                        <a href=\"https://alternativeto.net/software/changedetection-io/about/\" title=\"Web page change detection at alternativeto.net\">Rate us at\n                        AlternativeTo.net</a>\n                    </li>\n                <li>\n                    <a href=\"https://github.com/dgtlmoon/changedetection.io\" title=\"Web page change detection on GitHub\">Star us on GitHub</a>\n                </li>\n                <li>\n                    <a rel=\"nofollow\" href=\"https://twitter.com/change_det_io\" title=\"Web page change detection on Twitter\">Follow us at Twitter/X</a>\n                </li>\n                <li>\n                    <a rel=\"nofollow\" href=\"https://www.g2.com/products/changedetection-io/reviews\" title=\"Web page change detection reviews at G2\">G2 Software reviews</a>\n                </li>\n                <li>\n                    <a rel=\"nofollow\" href=\"https://www.linkedin.com/company/changedetection-io\" title=\"Visit web page change detection at LinkedIn\">Check us out on LinkedIn</a>\n                </li>\n                <li>\n                    And tell your friends and colleagues :)\n                </li>\n                </ul>\n                <p>\n                    The more popular changedetection.io is, the more time we can dedicate to adding amazing features!\n                </p>\n                <p>\n                    Many thanks :)<br>\n                </p>\n                <p>\n                    <i>changedetection.io team</i>\n                </p>\n            </div>\n        </div>\n\n        <div class=\"content-wrapper\">\n{#\n          {% if current_user.is_authenticated or not has_password %}\n          <aside class=\"action-sidebar\">\n            <a href=\"{{ url_for('watchlist.index') }}\" class=\"action-sidebar-item {% if request.endpoint.startswith('watchlist.') or request.endpoint.startswith('ui.') %}active{% endif %}\" title=\"{{ _('Watch List') }}\">\n              <svg class=\"action-icon\" viewBox=\"0 0 24 24\">\n                <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n                <path d=\"M12 6v6l4 2\"/>\n              </svg>\n              <span class=\"action-label\">{{ _('Watches') }}</span>\n            </a>\n            <a href=\"{{ url_for('queue_status') }}\" class=\"action-sidebar-item {% if request.endpoint == 'queue_status' %}active{% endif %}\" id=\"queue-action-item\" title=\"{{ _('Queue Status') }}\">\n              <svg class=\"action-icon\" viewBox=\"0 0 24 24\">\n                <line x1=\"8\" y1=\"6\" x2=\"21\" y2=\"6\"/>\n                <line x1=\"8\" y1=\"12\" x2=\"21\" y2=\"12\"/>\n                <line x1=\"8\" y1=\"18\" x2=\"21\" y2=\"18\"/>\n                <line x1=\"3\" y1=\"6\" x2=\"3.01\" y2=\"6\"/>\n                <line x1=\"3\" y1=\"12\" x2=\"3.01\" y2=\"12\"/>\n                <line x1=\"3\" y1=\"18\" x2=\"3.01\" y2=\"18\"/>\n              </svg>\n              <span class=\"action-label\">{{ _('Queue') }}</span>\n              <span class=\"notification-bubble blue-bubble\" id=\"queue-bubble\" data-count=\"0\"></span>\n            </a>\n          </aside>\n          {% endif %}\n#}\n          <div class=\"content-main\">\n            <header>\n              {% block header %}{% endblock %}\n            </header>\n\n            {% with messages = get_flashed_messages(with_categories = true) %}\n            {% if messages %}\n              <ul class=\"messages\">\n                {% for category, message in messages %}\n                  <li class=\"{{ category }}\">{{ message }}</li>\n                {% endfor %}\n              </ul>\n            {% endif %}\n            {% endwith %}\n            {% if session['share-link'] %}\n              <ul class=\"messages with-share-link\">\n                <li class=\"message\">\n                  Share this link:\n                  <span id=\"share-link\">{{ session['share-link'] }}</span>\n                  <img style=\"height: 1em; display: inline-block\" src=\"{{url_for('static_content', group='images', filename='copy.svg')}}\" >\n                </li>\n              </ul>\n            {% endif %}\n            {% block content %}{% endblock %}\n          </div>\n        </div>\n    </section>\n    <script src=\"{{url_for('static_content', group='js', filename='toggle-theme.js')}}\" defer></script>\n    <script src=\"{{url_for('static_content', group='js', filename='hamburger-menu.js')}}\" defer></script>\n\n    <div id=\"checking-now-fixed-tab\" style=\"display: none;\"><span class=\"spinner\"></span><span class=\"status-text\">&nbsp;{{ _('Checking now') }}</span></div>\n    <div id=\"realtime-conn-error\" style=\"display:none\">{{ _('Real-time updates offline') }}</div>\n    {% if bottom_horizontal_offscreen_contents %}\n        <div id=\"bottom-horizontal-offscreen\" style=\"display:none\">\n            {{ bottom_horizontal_offscreen_contents|safe }}\n        </div>\n    {% endif %}\n\n    <!-- Language Selection Modal -->\n    <dialog id=\"language-modal\" class=\"modal-dialog\" aria-labelledby=\"language-modal-title\">\n      <div class=\"modal-header\">\n        <h2 class=\"modal-title\" id=\"language-modal-title\">{{ _('Select Language') }}</h2>\n      </div>\n      <div class=\"modal-body\">\n        <div class=\"language-list\">\n          {% for locale, lang_data in available_languages.items()|sort %}\n          <a href=\"{{ url_for('set_language', locale=locale, redirect=request.path) }}\" class=\"language-option\" data-locale=\"{{ locale }}\">\n            <span class=\"lang-option {{ lang_data.flag }}\"></span> <span class=\"language-name\">{{ lang_data.name }}</span>\n          </a>\n          {% endfor %}\n        </div>\n        <div>\n            <a href=\"{{ url_for('ui.delete_locale_language_session_var_if_it_exists', redirect=request.path) }}\" >{{ _('Auto-detect from browser') }}</a>\n        </div>\n        <div>\n            {{ _('Language support is in beta, please help us improve by opening a PR on GitHub with any updates.') }}\n        </div>\n      </div>\n      <div class=\"modal-footer\">\n        <button type=\"button\" class=\"pure-button\" id=\"close-language-modal\">{{ _('Cancel') }}</button>\n      </div>\n    </dialog>\n\n    <!-- Search Modal -->\n    {% if current_user.is_authenticated or not has_password %}\n    <dialog id=\"search-modal\" class=\"modal-dialog\" aria-labelledby=\"search-modal-title\">\n      <div class=\"modal-header\">\n        <h2 class=\"modal-title\" id=\"search-modal-title\">{{ _('Search') }}</h2>\n      </div>\n      <div class=\"modal-body\">\n        <form id=\"search-form\" method=\"GET\">\n          <div class=\"pure-control-group\">\n            <label for=\"search-modal-input\">{{ _('URL or Title') }}{% if active_tag_uuid %} {{ _('in') }} '{{ active_tag.title }}'{% endif %}</label>\n            <input id=\"search-modal-input\" class=\"m-d\" name=\"q\" placeholder=\"{{ _('Enter search term...') }}\" required type=\"text\" value=\"\" autofocus>\n            <input name=\"tags\" type=\"hidden\" value=\"{% if active_tag_uuid %}{{active_tag_uuid}}{% endif %}\">\n          </div>\n        </form>\n      </div>\n      <div class=\"modal-footer\">\n        <button type=\"button\" class=\"pure-button button-cancel\" id=\"close-search-modal\">{{ _('Cancel') }}</button>\n        <button type=\"submit\" form=\"search-form\" class=\"pure-button pure-button-primary\">{{ _('Search') }}</button>\n      </div>\n    </dialog>\n    {% endif %}\n\n\n  <script>\n  (function() {\n      /* AUTOMATIC TAB COLUMN-IZER FOR WHEN TABS WRAP */\n    // Exit early if no tabs on page\n    if (!document.querySelector('.tab')) return;\n\n    const cache = new Map();\n\n    function checkWrapping(ul) {\n      const tabs = ul.querySelectorAll('.tab');\n      if (tabs.length < 2) return false;\n\n      // Init cache on first run\n      if (!cache.has(ul)) {\n        ul.style.setProperty('--tab-width', '');\n        void ul.offsetHeight;\n        let max = 0;\n        tabs.forEach(t => max = Math.max(max, t.offsetWidth));\n        cache.set(ul, max);\n      }\n\n      // Temporarily use flex wrap to check if wrapping occurs\n      ul.style.display = 'flex';\n      ul.style.flexWrap = 'wrap';\n      void ul.offsetHeight;\n\n      const top = tabs[0].offsetTop;\n      const wrapped = Array.from(tabs).some((t, i) => i > 0 && t.offsetTop !== top);\n\n      // Reset display to use CSS grid\n      ul.style.display = '';\n      ul.style.flexWrap = '';\n\n      // Set CSS variable for wrapped mode\n      if (wrapped) {\n        ul.style.setProperty('--tab-width', `${cache.get(ul) + 10}px`);\n      } else {\n        ul.style.setProperty('--tab-width', '');\n      }\n\n      return wrapped;\n    }\n\n    function check() {\n      let any = false;\n      document.querySelectorAll('ul').forEach(ul => {\n        if (ul.querySelector('.tab') && checkWrapping(ul)) any = true;\n      });\n      document.body.classList.toggle('wrapped-tabs', any);\n    }\n\n    check();\n    let timer;\n    window.addEventListener('resize', () => {\n      clearTimeout(timer);\n      timer = setTimeout(check, 100);\n    });\n\n    // Re-check wrapping when tabs are switched via anchors\n    window.addEventListener('hashchange', () => {\n      clearTimeout(timer);\n      // Use requestAnimationFrame + setTimeout to ensure DOM has settled\n      requestAnimationFrame(() => {\n        timer = setTimeout(check, 0);\n      });\n    });\n  })();\n  </script>\n\n\n    <script src=\"{{url_for('static_content', group='js', filename='language-selector.js')}}\" defer></script>\n    <script src=\"{{url_for('static_content', group='js', filename='search-modal.js')}}\" defer></script>\n    <script src=\"{{url_for('static_content', group='js', filename='toast.js')}}\"></script>\n    <script src=\"{{url_for('static_content', group='js', filename='flask-toast-bridge.js')}}\" defer></script>\n  </body>\n\n</html>\n"
  },
  {
    "path": "changedetectionio/templates/edit/include_subtract.html",
    "content": "                    <div class=\"pure-control-group\">\n                        {% set field = render_field(form.include_filters,\n                            rows=5,\n                            placeholder=has_tag_filters_extra+\"#example\nxpath://body/div/span[contains(@class, 'example-class')]\",\n                            class=\"m-d\")\n                        %}\n                        {{ field }}\n                        {% if '/text()' in  field %}\n                          <span class=\"pure-form-message-inline\"><strong>Note!: //text() function does not work where the &lt;element&gt; contains &lt;![CDATA[]]&gt;</strong></span><br>\n                        {% endif %}\n                        <span class=\"pure-form-message-inline\">One CSS, xPath 1 &amp; 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>\n                        <span data-target=\"#advanced-help-selectors\" class=\"toggle-show pure-button button-tag button-xsmall\">Show advanced help and tips</span><br>\n                    <ul id=\"advanced-help-selectors\" style=\"display: none;\">\n                        <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>\n                        <li>JSON - Limit text to this JSON rule, using either <a href=\"https://pypi.org/project/jsonpath-ng/\" target=\"new\">JSONPath</a> or <a href=\"https://stedolan.github.io/jq/\" target=\"new\">jq</a> (if installed).\n                            <ul>\n                                <li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required,  <a href=\"https://jsonpath.com/\" target=\"new\">test your JSONPath here</a>.</li>\n                                {% if jq_support %}\n                                <li>jq: Prefix with <code>jq:</code> and <a href=\"https://jqplay.org/\" target=\"new\">test your jq here</a>. Using <a href=\"https://stedolan.github.io/jq/\" target=\"new\">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href=\"https://stedolan.github.io/jq/manual/\" target=\"new\">here</a>. Prefix <code>jqraw:</code> outputs the results as text instead of a JSON list.</li>\n                                {% else %}\n                                <li>jq support not installed</li>\n                                {% endif %}\n                            </ul>\n                        </li>\n                        <li>XPath - Limit text to this XPath rule, simply start with a forward-slash. To specify XPath to be used explicitly or the XPath rule starts with an XPath function: Prefix with <code>xpath:</code>\n                            <ul>\n                                <li>Example:  <code>//*[contains(@class, 'sametext')]</code> or <code>xpath:count(//*[contains(@class, 'sametext')])</code>, <a\n                                href=\"http://xpather.com/\" target=\"new\">test your XPath here</a></li>\n                                <li>Example: Get all titles from an RSS feed <code>//title/text()</code></li>\n                                <li>To use XPath1.0: Prefix with <code>xpath1:</code></li>\n                            </ul>\n                            </li>\n                    <li>\n                        Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a\n                                href=\"https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help\">here for more CSS selector help</a>.<br>\n                    </li>\n                    </ul>\n\n                </span>\n                    </div>\n                <fieldset class=\"pure-control-group\">\n                    {{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+\"header\nfooter\nnav\n.stockticker\n//*[contains(text(), 'Advertisement')]\") }}\n                    <span class=\"pure-form-message-inline\">\n                        <ul>\n                          <li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>\n                          <li> Don't paste HTML here, use only CSS and XPath selectors </li>\n                          <li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>\n                        </ul>\n                      </span>\n                </fieldset>\n"
  },
  {
    "path": "changedetectionio/templates/edit/text-options.html",
    "content": "\n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.trigger_text, rows=5, placeholder=\"Some text to wait for in a line\n/some.regex\\d{2}/ for case-INsensitive regex\n\") }}\n                        <span class=\"pure-form-message-inline\">\n                    <ul>\n                        <li>{{ _('Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.') }}</li>\n                        <li>{{ _('Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>\n                        <li>{{ _('Each line is processed separately (think of each line as \"OR\")') }}</li>\n                        <li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\\d/</code></li>\n                        <li>{{ _('You can also use')}} <a href=\"#conditions\">{{ _('conditions')}}</a> - {{ _('\"Page text\" - with Contains, Starts With, Not Contains and many more' ) }} <code>/foo\\d/</code></li>\n                    </ul>\n                        </span>\n                    </div>\n                </fieldset>\n                <fieldset class=\"pure-group\">\n                    {{ render_field(form.ignore_text, rows=5, placeholder=\"Some text to ignore in a line\n/some.regex\\d{2}/ for case-INsensitive regex\n\") }}\n                    <span class=\"pure-form-message-inline\">\n                        <ul>\n                            <li>{{ _('Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)') }}</li>\n                            <li>{{ _('Each line processed separately, any line matching will be ignored (removed before creating the checksum)') }}</li>\n                            <li>{{ _('Regular Expression support, wrap the entire line in forward slash') }} <code>/regex/</code></li>\n                            <li>{{ _('Changing this will affect the comparison checksum which may trigger an alert') }}</li>\n                        </ul>\n                </span>\n                <br><br>\n                    <div class=\"pure-control-group\">\n                      {{ render_ternary_field(form.strip_ignored_lines) }}\n                    </div>\n                </fieldset>\n\n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.text_should_not_be_present, rows=5, placeholder=\"For example: Out of stock\nSold out\nNot in stock\nUnavailable\") }}\n                        <span class=\"pure-form-message-inline\">\n                            <ul>\n                                <li>{{ _('Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again') }}</li>\n                                <li>{{ _('Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>\n                                <li>{{ _('All lines here must not exist (think of each line as \"OR\")') }}</li>\n                                <li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\\d/</code></li>\n                            </ul>\n                        </span>\n                    </div>\n                </fieldset>\n                <fieldset>\n                    <div class=\"pure-control-group\">\n                        {{ render_field(form.extract_text, rows=5, placeholder=\"/.+?\\d+ comments.+?/\n or\nkeyword\") }}\n                        <span class=\"pure-form-message-inline\">\n                    <ul>\n                        <li>{{ _('Extracts text in the final output (line by line) after other filters using regular expressions or string match:') }}\n                            <ul>\n                                <li>{{ _('Regular expression - example') }} <code>/reports.+?2022/i</code></li>\n                                <li>{{ _('Don\\'t forget to consider the white-space at the start of a line') }} <code>/.+?reports.+?2022/i</code></li>\n                                <li>{{ _('Use') }} <code>//(?aiLmsux))</code> {{ _('type flags (more') }} <a href=\"https://docs.python.org/3/library/re.html#index-15\">{{ _('information here') }}</a>)<br></li>\n                                <li>{{ _('Keyword example - example') }} <code>Out of stock</code></li>\n                                <li>{{ _('Use groups to extract just that text - example') }} <code>/reports.+?(\\d+)/i</code> {{ _('returns a list of years only') }}</li>\n                                <li>{{ _('Example - match lines containing a keyword') }} <code>/.*icecream.*/</code></li>\n                            </ul>\n                        </li>\n                        <li>{{ _('One line per regular-expression/string match') }}</li>\n                    </ul>\n                        </span>\n                    </div>\n                </fieldset>\n"
  },
  {
    "path": "changedetectionio/templates/login.html",
    "content": "{% extends 'base.html' %}\n\n{% block content %}\n<div class=\"login-form\">\n <div class=\"inner\">\n    <form class=\"pure-form pure-form-stacked\" action=\"{{url_for('login')}}\" method=\"POST\">\n        <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\">\n        <input type=\"hidden\" id=\"redirect\" name=\"redirect\" value=\"{{ redirect_url }}\">\n        <fieldset>\n            <div class=\"pure-control-group\">\n                <label for=\"password\">{{ _('Password') }}</label>\n                <input type=\"password\" id=\"password\" required=\"\" name=\"password\" value=\"\"\n                       size=\"15\" autofocus />\n                <input type=\"hidden\" id=\"email\" name=\"email\" value=\"defaultuser@changedetection.io\" >\n            </div>\n            <div class=\"pure-control-group\">\n                <button type=\"submit\" class=\"pure-button pure-button-primary\">{{ _('Login') }}</button>\n            </div>\n        </fieldset>\n    </form>\n  </div>\n </div>\n\n{% endblock %}\n"
  },
  {
    "path": "changedetectionio/templates/menu.html",
    "content": "{# Menu items template - used for both desktop and mobile menus #}\n{# CSS media queries handle which version displays - no need for conditional classes #}\n\n\n{% if current_user.is_authenticated or not has_password %}\n{% if not current_diff_url %}\n    <li class=\"pure-menu-item menu-collapsible {% if request.endpoint.startswith('tags.') %}active{% endif %}\">\n        <a href=\"{{ url_for('tags.tags_overview_page') }}\" class=\"pure-menu-link\">{{ _('GROUPS') }}</a>\n    </li>\n    <li class=\"pure-menu-item menu-collapsible {% if request.endpoint.startswith('settings.') %}active{% endif %}\">\n        <a href=\"{{ url_for('settings.settings_page') }}\" class=\"pure-menu-link\">{{ _('SETTINGS') }}</a>\n    </li>\n    <li class=\"pure-menu-item menu-collapsible {% if request.endpoint.startswith('imports.') %}active{% endif %}\">\n        <a href=\"{{ url_for('imports.import_page') }}\" class=\"pure-menu-link\">{{ _('IMPORT') }}</a>\n    </li>\n    <li class=\"pure-menu-item\" id=\"menu-pause\">\n        <a href=\"{{ url_for('settings.toggle_all_paused') }}\" ><img src=\"{{url_for('static_content', group='images', filename='pause.svg')}}\" alt=\"{% if all_paused %}{{ _('Resume automatic scheduling') }}{% else %}{{ _('Pause auto-queue scheduling of watches') }}{% endif %}\" title=\"{% if all_paused %}{{ _('Scheduling is paused - click to resume') }}{% else %}{{ _('Pause auto-queue scheduling of watches') }}{% endif %}\" class=\"icon icon-pause\"{% if not all_paused %} style=\"opacity: 0.3\"{% endif %}></a>\n    </li>\n    <li class=\"pure-menu-item \" id=\"menu-mute\">\n        <a href=\"{{ url_for('settings.toggle_all_muted') }}\" ><img src=\"{{url_for('static_content', group='images', filename='bell-off.svg')}}\" alt=\"{% if all_muted %}{{ _('Unmute notifications') }}{% else %}{{ _('Mute notifications') }}{% endif %}\" title=\"{% if all_muted %}{{ _('Notifications are muted - click to unmute') }}{% else %}{{ _('Mute notifications') }}{% endif %}\" class=\"icon icon-mute\"{% if not all_muted %} style=\"opacity: 0.3\"{% endif %}></a>\n    </li>\n{% else %}\n    <li class=\"pure-menu-item menu-collapsible\">\n        <a href=\"{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}\"\n           class=\"pure-menu-link\">{{ _('EDIT') }}</a>\n    </li>\n{% endif %}\n{%- if current_user.is_authenticated -%}\n    <li class=\"pure-menu-item menu-collapsible\">\n        <a href=\"{{ url_for('logout', redirect=request.path) }}\" class=\"pure-menu-link\">{{ _('LOG OUT') }}</a>\n    </li>\n{%- endif -%}\n\n{% else %}\n    <li class=\"pure-menu-item menu-collapsible\">\n        <a class=\"pure-menu-link\" href=\"https://changedetection.io\">{{ _('Website Change Detection and Notification.') }}</a>\n    </li>\n{% endif %}\n    <li class=\"pure-menu-item menu-collapsible\" id=\"inline-menu-extras-group\">\n        <button class=\"toggle-button toggle-light-mode \" type=\"button\" title=\"{{ _('Toggle Light/Dark Mode') }}\">\n            <span class=\"visually-hidden\">{{ _('Toggle light/dark mode') }}</span>\n            <span class=\"icon-light\">\n            {% include \"svgs/light-mode-toggle-icon.svg\" %}\n          </span>\n            <span class=\"icon-dark\">\n            {% include \"svgs/dark-mode-toggle-icon.svg\" %}\n          </span>\n        </button>\n        <button class=\"toggle-button language-selector\" type=\"button\" title=\"{{ _('Change Language') }}\">\n            <span class=\"visually-hidden\">{{ _('Change language') }}</span>\n            <span class=\"{{ get_flag_for_locale(get_locale()) }}\" id=\"language-selector-flag\"></span>\n        </button>\n        <a class=\"github-link\" href=\"https://github.com/dgtlmoon/changedetection.io\"\n           target=\"_blank\" \n          rel=\"noopener\" >\n            {% include \"svgs/github.svg\" %}\n        </a>\n    </li>\n\n"
  },
  {
    "path": "changedetectionio/test_cli_opts.sh",
    "content": "#!/bin/bash\n# Test script for CLI options - Parallel execution\n# Tests -u, -uN, -r, -b flags\n\nset -u  # Exit on undefined variables\n\n# Color output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\n# Test results directory (for parallel safety)\nTEST_RESULTS_DIR=\"/tmp/cli-test-results-$$\"\nmkdir -p \"$TEST_RESULTS_DIR\"\n\n# Cleanup function\ncleanup() {\n    echo \"\"\n    echo \"=== Cleaning up test directories ===\"\n    rm -rf /tmp/cli-test-* 2>/dev/null || true\n    rm -rf \"$TEST_RESULTS_DIR\" 2>/dev/null || true\n    # Kill any hanging processes\n    pkill -f \"changedetection.py.*cli-test\" 2>/dev/null || true\n}\ntrap cleanup EXIT\n\n# Helper to record test result\nrecord_result() {\n    local test_num=$1\n    local status=$2  # pass or fail\n    local message=$3\n\n    echo \"$status|$message\" > \"$TEST_RESULTS_DIR/test_${test_num}.result\"\n}\n\n# Run a test in background\nrun_test() {\n    local test_num=$1\n    local test_name=$2\n    local test_func=$3\n\n    (\n        echo -e \"${YELLOW}[Test $test_num]${NC} $test_name\"\n        if $test_func \"$test_num\"; then\n            record_result \"$test_num\" \"pass\" \"$test_name\"\n            echo -e \"${GREEN}✓ PASS${NC}: $test_name\"\n        else\n            record_result \"$test_num\" \"fail\" \"$test_name\"\n            echo -e \"${RED}✗ FAIL${NC}: $test_name\"\n        fi\n    ) &\n}\n\n# =============================================================================\n# Test Functions (each runs independently)\n# =============================================================================\n\ntest_help_flag() {\n    local test_id=$1\n    timeout 3 python3 changedetection.py --help 2>&1 | grep -q \"Add URLs on startup\"\n}\n\ntest_version_flag() {\n    local test_id=$1\n    timeout 3 python3 changedetection.py --version 2>&1 | grep -qE \"changedetection.io [0-9]+\\.[0-9]+\"\n}\n\ntest_single_url() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-single-${test_id}-$$\"\n    timeout 10 python3 changedetection.py -d \"$dir\" -C -u https://example.com -b &>/dev/null\n    # Count watch directories (UUID directories containing watch.json)\n    [ \"$(find \"$dir\" -mindepth 2 -maxdepth 2 -name 'watch.json' | wc -l)\" -eq 1 ]\n}\n\ntest_multiple_urls() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-multi-${test_id}-$$\"\n    timeout 12 python3 changedetection.py -d \"$dir\" -C \\\n        -u https://example.com \\\n        -u https://github.com \\\n        -u https://httpbin.org \\\n        -b &>/dev/null\n    # Count watch directories (UUID directories containing watch.json)\n    [ \"$(find \"$dir\" -mindepth 2 -maxdepth 2 -name 'watch.json' | wc -l)\" -eq 3 ]\n}\n\ntest_url_with_options() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-opts-${test_id}-$$\"\n    timeout 10 python3 changedetection.py -d \"$dir\" -C \\\n        -u https://example.com \\\n        -u0 '{\"title\":\"Test Site\",\"processor\":\"text_json_diff\"}' \\\n        -b &>/dev/null\n    # Check that at least one watch.json contains the title \"Test Site\"\n    python3 -c \"\nimport json, glob, sys\nwatch_files = glob.glob('$dir/*/watch.json')\nfor wf in watch_files:\n    with open(wf) as f:\n        data = json.load(f)\n        if data.get('title') == 'Test Site':\n            sys.exit(0)\nsys.exit(1)\n\"\n}\n\ntest_multiple_urls_with_options() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-multi-opts-${test_id}-$$\"\n    timeout 12 python3 changedetection.py -d \"$dir\" -C \\\n        -u https://example.com \\\n        -u0 '{\"title\":\"Site One\"}' \\\n        -u https://github.com \\\n        -u1 '{\"title\":\"Site Two\"}' \\\n        -b &>/dev/null\n    # Check that we have 2 watches and both titles are present\n    python3 -c \"\nimport json, glob, sys\nwatch_files = glob.glob('$dir/*/watch.json')\nif len(watch_files) != 2:\n    sys.exit(1)\ntitles = []\nfor wf in watch_files:\n    with open(wf) as f:\n        data = json.load(f)\n        titles.append(data.get('title'))\nsys.exit(0 if 'Site One' in titles and 'Site Two' in titles else 1)\n\"\n}\n\ntest_batch_mode_exit() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-batch-${test_id}-$$\"\n    local start=$(date +%s)\n    timeout 15 python3 changedetection.py -d \"$dir\" -C \\\n        -u https://example.com \\\n        -b &>/dev/null\n    local end=$(date +%s)\n    local elapsed=$((end - start))\n    [ $elapsed -lt 14 ]\n}\n\ntest_recheck_all() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-recheck-all-${test_id}-$$\"\n    # Create a watch using CLI, then recheck it\n    timeout 10 python3 changedetection.py -d \"$dir\" -C -u https://example.com -b &>/dev/null\n    # Now recheck all watches\n    timeout 10 python3 changedetection.py -d \"$dir\" -r all -b 2>&1 | grep -q \"Queuing\"\n}\n\ntest_recheck_specific() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-recheck-uuid-${test_id}-$$\"\n    # Create 2 watches using CLI\n    timeout 12 python3 changedetection.py -d \"$dir\" -C \\\n        -u https://example.com \\\n        -u https://github.com \\\n        -b &>/dev/null\n    # Get the UUIDs that were created\n    local uuids=$(find \"$dir\" -mindepth 2 -maxdepth 2 -name 'watch.json' -exec dirname {} \\; | xargs -n1 basename | tr '\\n' ',' | sed 's/,$//')\n    # Now recheck specific UUIDs\n    timeout 10 python3 changedetection.py -d \"$dir\" -r \"$uuids\" -b 2>&1 | grep -q \"Queuing\"\n}\n\ntest_combined_operations() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-combined-${test_id}-$$\"\n    timeout 12 python3 changedetection.py -d \"$dir\" -C \\\n        -u https://example.com \\\n        -u https://github.com \\\n        -r all \\\n        -b &>/dev/null\n    # Count watch directories (UUID directories containing watch.json)\n    [ \"$(find \"$dir\" -mindepth 2 -maxdepth 2 -name 'watch.json' | wc -l)\" -eq 2 ]\n}\n\ntest_invalid_json() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-invalid-${test_id}-$$\"\n    timeout 5 python3 changedetection.py -d \"$dir\" -C \\\n        -u https://example.com \\\n        -u0 'invalid json here' \\\n        2>&1 | grep -qi \"invalid json\\|json decode error\"\n}\n\ntest_create_directory() {\n    local test_id=$1\n    local dir=\"/tmp/cli-test-create-${test_id}-$$/nested/path\"\n    timeout 10 python3 changedetection.py -d \"$dir\" -C \\\n        -u https://example.com \\\n        -b &>/dev/null\n    [ -d \"$dir\" ]\n}\n\n# =============================================================================\n# Main Test Execution\n# =============================================================================\n\necho \"==========================================\"\necho \"  CLI Options Test Suite (Parallel)\"\necho \"==========================================\"\necho \"\"\n\n# Launch all tests in parallel\nrun_test 1 \"Help flag (--help) shows usage without initialization\" test_help_flag\nrun_test 2 \"Version flag (--version) displays version\" test_version_flag\nrun_test 3 \"Add single URL with -u flag\" test_single_url\nrun_test 4 \"Add multiple URLs with multiple -u flags\" test_multiple_urls\nrun_test 5 \"Add URL with JSON options using -u0\" test_url_with_options\nrun_test 6 \"Add multiple URLs with different options (-u0, -u1)\" test_multiple_urls_with_options\nrun_test 7 \"Batch mode (-b) exits automatically after processing\" test_batch_mode_exit\nrun_test 8 \"Recheck all watches with -r all\" test_recheck_all\nrun_test 9 \"Recheck specific watches with -r UUID\" test_recheck_specific\nrun_test 10 \"Combined: Add URLs and recheck all with -u and -r all\" test_combined_operations\nrun_test 11 \"Invalid JSON in -u0 option should show error\" test_invalid_json\nrun_test 12 \"Create datastore directory with -C flag\" test_create_directory\n\n# Wait for all tests to complete\necho \"\"\necho \"Waiting for all tests to complete...\"\nwait\n\n# Collect results\necho \"\"\necho \"==========================================\"\necho \"  Test Summary\"\necho \"==========================================\"\n\nTESTS_RUN=0\nTESTS_PASSED=0\nTESTS_FAILED=0\n\nfor result_file in \"$TEST_RESULTS_DIR\"/test_*.result; do\n    if [ -f \"$result_file\" ]; then\n        TESTS_RUN=$((TESTS_RUN + 1))\n        status=$(cut -d'|' -f1 < \"$result_file\")\n        if [ \"$status\" = \"pass\" ]; then\n            TESTS_PASSED=$((TESTS_PASSED + 1))\n        else\n            TESTS_FAILED=$((TESTS_FAILED + 1))\n        fi\n    fi\ndone\n\necho \"Tests run:    $TESTS_RUN\"\necho -e \"${GREEN}Tests passed: $TESTS_PASSED${NC}\"\nif [ $TESTS_FAILED -gt 0 ]; then\n    echo -e \"${RED}Tests failed: $TESTS_FAILED${NC}\"\nelse\n    echo -e \"${GREEN}Tests failed: $TESTS_FAILED${NC}\"\nfi\necho \"==========================================\"\necho \"\"\n\n# Exit with appropriate code\nif [ $TESTS_FAILED -gt 0 ]; then\n    echo -e \"${RED}Some tests failed!${NC}\"\n    exit 1\nelse\n    echo -e \"${GREEN}All tests passed!${NC}\"\n    exit 0\nfi\n"
  },
  {
    "path": "changedetectionio/tests/__init__.py",
    "content": "\"\"\"Tests for the app.\"\"\"\n\n"
  },
  {
    "path": "changedetectionio/tests/apprise/test_apprise_asset.py",
    "content": "import pytest\nfrom apprise import AppriseAsset\n\nfrom changedetectionio.apprise_asset import (\n    APPRISE_APP_DESC,\n    APPRISE_APP_ID,\n    APPRISE_APP_URL,\n    APPRISE_AVATAR_URL,\n)\n\n\n@pytest.fixture(scope=\"function\")\ndef apprise_asset() -> AppriseAsset:\n    from changedetectionio.apprise_asset import apprise_asset\n\n    return apprise_asset\n\n\ndef test_apprise_asset_init(apprise_asset: AppriseAsset):\n    assert isinstance(apprise_asset, AppriseAsset)\n    assert apprise_asset.app_id == APPRISE_APP_ID\n    assert apprise_asset.app_desc == APPRISE_APP_DESC\n    assert apprise_asset.app_url == APPRISE_APP_URL\n    assert apprise_asset.image_url_logo == APPRISE_AVATAR_URL\n"
  },
  {
    "path": "changedetectionio/tests/apprise/test_apprise_custom_api_call.py",
    "content": "import json\nfrom unittest.mock import patch\n\nimport pytest\nimport requests\nfrom apprise.utils.parse import parse_url as apprise_parse_url\n\nfrom ...apprise_plugin.custom_handlers import (\n    _get_auth,\n    _get_headers,\n    _get_params,\n    apprise_http_custom_handler,\n    SUPPORTED_HTTP_METHODS,\n)\n\n\n@pytest.mark.parametrize(\n    \"url,expected_auth\",\n    [\n        (\"get://user:pass@localhost:9999\", (\"user\", \"pass\")),\n        (\"get://user@localhost:9999\", \"user\"),\n        (\"get://localhost:9999\", \"\"),\n        (\"get://user%20name:pass%20word@localhost:9999\", (\"user name\", \"pass word\")),\n    ],\n)\ndef test_get_auth(url, expected_auth):\n    \"\"\"Test authentication extraction with various URL formats.\"\"\"\n    parsed_url = apprise_parse_url(url)\n    assert _get_auth(parsed_url) == expected_auth\n\n\n@pytest.mark.parametrize(\n    \"url,body,expected_content_type\",\n    [\n        (\n            \"get://localhost:9999?+content-type=application/xml\",\n            \"test\",\n            \"application/xml\",\n        ),\n        (\"get://localhost:9999\", '{\"key\": \"value\"}', \"application/json; charset=utf-8\"),\n        (\"get://localhost:9999\", \"plain text\", None),\n        (\"get://localhost:9999?+content-type=text/plain\", \"test\", \"text/plain\"),\n    ],\n)\ndef test_get_headers(url, body, expected_content_type):\n    \"\"\"Test header extraction and content type detection.\"\"\"\n    parsed_url = apprise_parse_url(url)\n    headers = _get_headers(parsed_url, body)\n\n    if expected_content_type:\n        assert headers.get(\"Content-Type\") == expected_content_type\n\n\n@pytest.mark.parametrize(\n    \"url,expected_params\",\n    [\n        (\"get://localhost:9999?param1=value1\", {\"param1\": \"value1\"}),\n        (\"get://localhost:9999?param1=value1&-param2=ignored\", {\"param1\": \"value1\"}),\n        (\"get://localhost:9999?param1=value1&+header=test\", {\"param1\": \"value1\"}),\n        (\n            \"get://localhost:9999?encoded%20param=encoded%20value\",\n            {\"encoded param\": \"encoded value\"},\n        ),\n    ],\n)\ndef test_get_params(url, expected_params):\n    \"\"\"Test parameter extraction with URL encoding and exclusion logic.\"\"\"\n    parsed_url = apprise_parse_url(url)\n    params = _get_params(parsed_url)\n    assert dict(params) == expected_params\n\n\n@pytest.mark.parametrize(\n    \"url,schema,method\",\n    [\n        (\"get://localhost:9999\", \"get\", \"GET\"),\n        (\"post://localhost:9999\", \"post\", \"POST\"),\n        (\"delete://localhost:9999\", \"delete\", \"DELETE\"),\n    ],\n)\n@patch(\"requests.request\")\ndef test_apprise_custom_api_call_success(mock_request, url, schema, method):\n    \"\"\"Test successful API calls with different HTTP methods and schemas.\"\"\"\n    mock_request.return_value.raise_for_status.return_value = None\n\n    meta = {\"url\": url, \"schema\": schema}\n    result = apprise_http_custom_handler(\n        body=\"test body\", title=\"Test Title\", notify_type=\"info\", meta=meta\n    )\n\n    assert result is True\n    mock_request.assert_called_once()\n\n    call_args = mock_request.call_args\n    assert call_args[1][\"method\"] == method.upper()\n    assert call_args[1][\"url\"].startswith(\"http\")\n\n\n@patch(\"requests.request\")\ndef test_apprise_custom_api_call_with_auth(mock_request):\n    \"\"\"Test API call with authentication.\"\"\"\n    mock_request.return_value.raise_for_status.return_value = None\n\n    url = \"get://user:pass@localhost:9999/secure\"\n    meta = {\"url\": url, \"schema\": \"get\"}\n\n    result = apprise_http_custom_handler(\n        body=json.dumps({\"key\": \"value\"}),\n        title=\"Secure Test\",\n        notify_type=\"info\",\n        meta=meta,\n    )\n\n    assert result is True\n    mock_request.assert_called_once()\n    call_args = mock_request.call_args\n    assert call_args[1][\"auth\"] == (\"user\", \"pass\")\n\n\n@pytest.mark.parametrize(\n    \"exception_type,expected_result\",\n    [\n        (requests.RequestException, False),\n        (requests.HTTPError, False),\n        (Exception, False),\n    ],\n)\n@patch(\"requests.request\")\ndef test_apprise_custom_api_call_failure(mock_request, exception_type, expected_result):\n    \"\"\"Test various failure scenarios.\"\"\"\n    url = \"get://localhost:9999/error\"\n    meta = {\"url\": url, \"schema\": \"get\"}\n\n    # Simulate different types of exceptions\n    mock_request.side_effect = exception_type(\"Error occurred\")\n\n    result = apprise_http_custom_handler(\n        body=\"error body\", title=\"Error Test\", notify_type=\"error\", meta=meta\n    )\n\n    assert result == expected_result\n\n\ndef test_invalid_url_parsing():\n    \"\"\"Test handling of invalid URL parsing.\"\"\"\n    meta = {\"url\": \"invalid://url\", \"schema\": \"invalid\"}\n    result = apprise_http_custom_handler(\n        body=\"test\", title=\"Invalid URL\", notify_type=\"info\", meta=meta\n    )\n\n    assert result is False\n\n\n@pytest.mark.parametrize(\n    \"schema,expected_method\",\n    [\n        (http_method, http_method.upper())\n        for http_method in SUPPORTED_HTTP_METHODS\n    ],\n)\n@patch(\"requests.request\")\ndef test_http_methods(mock_request, schema, expected_method):\n    \"\"\"Test all supported HTTP methods.\"\"\"\n    mock_request.return_value.raise_for_status.return_value = None\n\n    url = f\"{schema}://localhost:9999\"\n\n    result = apprise_http_custom_handler(\n        body=\"test body\",\n        title=\"Test Title\",\n        notify_type=\"info\",\n        meta={\"url\": url, \"schema\": schema},\n    )\n\n    assert result is True\n    mock_request.assert_called_once()\n\n    call_args = mock_request.call_args\n    assert call_args[1][\"method\"] == expected_method\n\n\n@pytest.mark.parametrize(\n    \"input_schema,expected_method\",\n    [\n        (f\"{http_method}s\", http_method.upper())\n        for http_method in SUPPORTED_HTTP_METHODS\n    ],\n)\n@patch(\"requests.request\")\ndef test_https_method_conversion(\n    mock_request, input_schema, expected_method\n):\n    \"\"\"Validate that methods ending with 's' use HTTPS and correct HTTP method.\"\"\"\n    mock_request.return_value.raise_for_status.return_value = None\n\n    url = f\"{input_schema}://localhost:9999\"\n\n    result = apprise_http_custom_handler(\n        body=\"test body\",\n        title=\"Test Title\",\n        notify_type=\"info\",\n        meta={\"url\": url, \"schema\": input_schema},\n    )\n\n    assert result is True\n    mock_request.assert_called_once()\n\n    call_args = mock_request.call_args\n\n    assert call_args[1][\"method\"] == expected_method\n    assert call_args[1][\"url\"].startswith(\"https\")\n"
  },
  {
    "path": "changedetectionio/tests/conftest.py",
    "content": "#!/usr/bin/env python3\nimport psutil\nimport time\nfrom threading import Thread\nimport multiprocessing\n\nimport pytest\nimport arrow\nfrom changedetectionio import store\nimport os\nimport sys\n\n# CRITICAL: Set short timeout for tests to prevent 45-second hangs\n# When test server is slow/unresponsive, workers fail fast instead of holding UUIDs for 45s\n# This prevents exponential priority growth from repeated deferrals (priority × 10 each defer)\nos.environ['DEFAULT_SETTINGS_REQUESTS_TIMEOUT'] = '5'\n# Test server runs on localhost (127.0.0.1) which is a private IP.\n# Allow it globally so all existing tests keep working; test_ssrf_protection\n# uses monkeypatch to temporarily override this for its own assertions.\nos.environ['ALLOW_IANA_RESTRICTED_ADDRESSES'] = 'true'\n\nfrom changedetectionio.flask_app import init_app_secret, changedetection_app\nfrom changedetectionio.tests.util import live_server_setup, new_live_server_setup\n\n# https://github.com/pallets/flask/blob/1.1.2/examples/tutorial/tests/test_auth.py\n# Much better boilerplate than the docs\n# https://www.python-boilerplate.com/py3+flask+pytest/\n\nglobal app\n\n# https://loguru.readthedocs.io/en/latest/resources/migration.html#replacing-caplog-fixture-from-pytest-library\n# Show loguru logs only if CICD pytest fails.\nfrom loguru import logger\n@pytest.fixture\ndef reportlog(pytestconfig):\n    logging_plugin = pytestconfig.pluginmanager.getplugin(\"logging-plugin\")\n    handler_id = logger.add(logging_plugin.report_handler, format=\"{message}\")\n    yield\n    logger.remove(handler_id)\n\n\n@pytest.fixture(autouse=True)\ndef per_test_log_file(request):\n    \"\"\"Create a separate log file for each test function with pytest output.\"\"\"\n    import re\n\n    # Create logs directory if it doesn't exist\n    log_dir = os.path.join(os.path.dirname(__file__), \"logs\")\n    os.makedirs(log_dir, exist_ok=True)\n\n    # Generate log filename from test name and worker ID (for parallel runs)\n    test_name = request.node.name\n\n    # Sanitize test name - replace unsafe characters with underscores\n    # Keep only alphanumeric, dash, underscore, and period\n    safe_test_name = re.sub(r'[^\\w\\-.]', '_', test_name)\n\n    # Limit length to avoid filesystem issues (max 200 chars)\n    if len(safe_test_name) > 200:\n        # Keep first 150 chars + hash of full name + last 30 chars\n        import hashlib\n        name_hash = hashlib.md5(test_name.encode()).hexdigest()[:8]\n        safe_test_name = f\"{safe_test_name[:150]}_{name_hash}_{safe_test_name[-30:]}\"\n\n    worker_id = os.environ.get('PYTEST_XDIST_WORKER', 'master')\n    log_file = os.path.join(log_dir, f\"{safe_test_name}_{worker_id}.log\")\n\n    # Add file handler for this test with TRACE level\n    handler_id = logger.add(\n        log_file,\n        format=\"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {process} | {name}:{function}:{line} - {message}\",\n        level=\"TRACE\",\n        mode=\"w\",  # Overwrite if exists\n        enqueue=True  # Thread-safe\n    )\n\n    logger.info(f\"=== Starting test: {test_name} (worker: {worker_id}) ===\")\n    logger.info(f\"Test location: {request.node.nodeid}\")\n\n    yield\n\n    # Capture test outcome (PASSED/FAILED/SKIPPED/ERROR)\n    outcome = \"UNKNOWN\"\n    exc_info = None\n    stdout = None\n    stderr = None\n\n    if hasattr(request.node, 'rep_call'):\n        outcome = request.node.rep_call.outcome.upper()\n        if request.node.rep_call.failed:\n            exc_info = request.node.rep_call.longreprtext\n        # Capture stdout/stderr from call phase\n        if hasattr(request.node.rep_call, 'sections'):\n            for section_name, section_content in request.node.rep_call.sections:\n                if 'stdout' in section_name.lower():\n                    stdout = section_content\n                elif 'stderr' in section_name.lower():\n                    stderr = section_content\n    elif hasattr(request.node, 'rep_setup'):\n        if request.node.rep_setup.failed:\n            outcome = \"SETUP_FAILED\"\n            exc_info = request.node.rep_setup.longreprtext\n\n    logger.info(f\"=== Test Result: {outcome} ===\")\n\n    if exc_info:\n        logger.error(f\"=== Test Failure Details ===\\n{exc_info}\")\n\n    if stdout:\n        logger.info(f\"=== Captured stdout ===\\n{stdout}\")\n\n    if stderr:\n        logger.warning(f\"=== Captured stderr ===\\n{stderr}\")\n\n    logger.info(f\"=== Finished test: {test_name} ===\")\n    logger.remove(handler_id)\n\n\n@pytest.hookimpl(tryfirst=True, hookwrapper=True)\ndef pytest_runtest_makereport(item, call):\n    \"\"\"Hook to capture test results and attach to the test node.\"\"\"\n    outcome = yield\n    rep = outcome.get_result()\n\n    # Store report on the test node for access in fixtures\n    setattr(item, f\"rep_{rep.when}\", rep)\n\n\n@pytest.fixture\ndef environment(mocker):\n    \"\"\"Mock arrow.now() to return a fixed datetime for testing jinja2 time extension.\"\"\"\n    # Fixed datetime: Wed, 09 Dec 2015 23:33:01 UTC\n    # This is calculated to match the test expectations when offsets are applied\n    fixed_datetime = arrow.Arrow(2015, 12, 9, 23, 33, 1, tzinfo='UTC')\n    # Patch arrow.now in the TimeExtension module where it's actually used\n    mocker.patch('changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now', return_value=fixed_datetime)\n    return fixed_datetime\n\n\ndef format_memory_human(bytes_value):\n    \"\"\"Format memory in human-readable units (KB, MB, GB)\"\"\"\n    if bytes_value < 1024:\n        return f\"{bytes_value} B\"\n    elif bytes_value < 1024 ** 2:\n        return f\"{bytes_value / 1024:.2f} KB\"\n    elif bytes_value < 1024 ** 3:\n        return f\"{bytes_value / (1024 ** 2):.2f} MB\"\n    else:\n        return f\"{bytes_value / (1024 ** 3):.2f} GB\"\n\ndef track_memory(memory_usage, ):\n    process = psutil.Process(os.getpid())\n    while not memory_usage[\"stop\"]:\n        current_rss = process.memory_info().rss\n        memory_usage[\"peak\"] = max(memory_usage[\"peak\"], current_rss)\n        memory_usage[\"current\"] = current_rss  # Keep updating current\n        time.sleep(0.01)  # Adjust the sleep time as needed\n\n@pytest.fixture(scope='function')\ndef measure_memory_usage(request):\n    memory_usage = {\"peak\": 0, \"current\": 0, \"stop\": False}\n    tracker_thread = Thread(target=track_memory, args=(memory_usage,))\n    tracker_thread.start()\n\n    yield\n\n    memory_usage[\"stop\"] = True\n    tracker_thread.join()\n\n    # Note: psutil returns RSS memory in bytes\n    peak_human = format_memory_human(memory_usage[\"peak\"])\n\n    s = f\"{time.time()} {request.node.fspath} - '{request.node.name}' - Peak memory: {peak_human}\"\n    logger.debug(s)\n\n    with open(\"test-memory.log\", 'a') as f:\n        f.write(f\"{s}\\n\")\n\n    # Assert that the memory usage is less than 200MB\n#    assert peak_memory_kb < 150 * 1024, f\"Memory usage exceeded 150MB: {peak_human}\"\n\n\ndef cleanup(datastore_path):\n    import glob\n    # Unlink test output files\n    for g in [\"*.txt\", \"*.json\", \"*.pdf\"]:\n        files = glob.glob(os.path.join(datastore_path, g))\n        for f in files:\n            if 'proxies.json' in f:\n                # Usually mounted by docker container during test time\n                continue\n            if os.path.isfile(f):\n                os.unlink(f)\n\ndef pytest_configure(config):\n    \"\"\"Configure pytest environment before tests run.\n\n    CRITICAL: Set multiprocessing start method to 'fork' for Python 3.14+ compatibility.\n\n    Python 3.14 changed the default start method from 'fork' to 'forkserver' on Linux.\n    The forkserver method requires all objects to be picklable, but pytest-flask's\n    LiveServer uses nested functions that can't be pickled.\n\n    Setting 'fork' explicitly:\n    - Maintains compatibility with Python 3.10-3.13 (where 'fork' was already default)\n    - Fixes Python 3.14 pickling errors\n    - Only affects Unix-like systems (Windows uses 'spawn' regardless)\n\n    See: https://github.com/python/cpython/issues/126831\n    See: https://docs.python.org/3/whatsnew/3.14.html\n    \"\"\"\n    # Only set if not already set (respects existing configuration)\n    if multiprocessing.get_start_method(allow_none=True) is None:\n        try:\n            # 'fork' is available on Unix-like systems (Linux, macOS)\n            # On Windows, this will have no effect as 'spawn' is the only option\n            multiprocessing.set_start_method('fork', force=False)\n            logger.debug(\"Set multiprocessing start method to 'fork' for Python 3.14+ compatibility\")\n        except (ValueError, RuntimeError):\n            # Already set, not available on this platform, or context already created\n            pass\n\ndef pytest_addoption(parser):\n    \"\"\"Add custom command-line options for pytest.\n\n    Provides --datastore-path option for specifying custom datastore location.\n    Note: Cannot use -d short option as it's reserved by pytest for debug mode.\n    \"\"\"\n    parser.addoption(\n        \"--datastore-path\",\n        action=\"store\",\n        default=None,\n        help=\"Custom datastore path for tests\"\n    )\n\n@pytest.fixture(scope='session')\ndef datastore_path(tmp_path_factory, request):\n    \"\"\"Provide datastore path unique to this worker.\n\n    Supports custom path via --datastore-path/-d flag (mirrors main app).\n\n    CRITICAL for xdist isolation:\n    - Each WORKER gets its own directory\n    - Tests on same worker run SEQUENTIALLY and cleanup between tests\n    - No subdirectories needed since tests don't overlap on same worker\n    - Example: /tmp/test-datastore-gw0/ for worker gw0\n    \"\"\"\n    # Check for custom path first (mirrors main app's -d flag)\n    custom_path = request.config.getoption(\"--datastore-path\")\n    if custom_path:\n        # Ensure the directory exists\n        os.makedirs(custom_path, exist_ok=True)\n        logger.info(f\"Using custom datastore path: {custom_path}\")\n        return custom_path\n\n    # Otherwise use default tmp_path_factory logic\n    worker_id = getattr(request.config, 'workerinput', {}).get('workerid', 'master')\n    if worker_id == 'master':\n        path = tmp_path_factory.mktemp(\"test-datastore\")\n    else:\n        path = tmp_path_factory.mktemp(f\"test-datastore-{worker_id}\")\n    return str(path)\n\n\n@pytest.fixture(scope='function', autouse=True)\ndef prepare_test_function(live_server, datastore_path):\n    \"\"\"Prepare each test with complete isolation.\n\n    CRITICAL for xdist per-test isolation:\n    - Reuses the SAME datastore instance (so blueprint references stay valid)\n    - Clears all watches and state for a clean slate\n    - First watch will get uuid=\"first\"\n    \"\"\"\n    routes = [rule.rule for rule in live_server.app.url_map.iter_rules()]\n    if '/test-random-content-endpoint' not in routes:\n        logger.debug(\"Setting up test URL routes\")\n        new_live_server_setup(live_server)\n\n    # CRITICAL: Point app to THIS test's unique datastore directory\n    live_server.app.config['TEST_DATASTORE_PATH'] = datastore_path\n\n    # CRITICAL: Get datastore and stop it from writing stale data\n    datastore = live_server.app.config.get('DATASTORE')\n\n    # Clear the queue before starting the test to prevent state leakage\n    from changedetectionio.flask_app import update_q\n    while not update_q.empty():\n        try:\n            update_q.get_nowait()\n        except:\n            break\n\n    # Add test helper methods to the app for worker management\n    def set_workers(count):\n        \"\"\"Set the number of workers for testing - brutal shutdown, no delays\"\"\"\n        from changedetectionio import worker_pool\n        from changedetectionio.flask_app import update_q, notification_q\n\n        current_count = worker_pool.get_worker_count()\n\n       # Special case: Setting to 0 means shutdown all workers brutally\n        if count == 0:\n            logger.debug(f\"Brutally shutting down all {current_count} workers\")\n            worker_pool.shutdown_workers()\n            return {\n                'status': 'success',\n                'message': f'Shutdown all {current_count} workers',\n                'previous_count': current_count,\n                'current_count': 0\n            }\n\n        # Adjust worker count (no delays, no verification)\n        result = worker_pool.adjust_async_worker_count(\n            count,\n            update_q=update_q,\n            notification_q=notification_q,\n            app=live_server.app,\n            datastore=datastore\n        )\n\n        return result\n\n    def check_all_workers_alive(expected_count):\n        \"\"\"Check that all expected workers are alive\"\"\"\n        from changedetectionio import worker_pool\n        from changedetectionio.flask_app import update_q, notification_q\n        result = worker_pool.check_worker_health(\n            expected_count,\n            update_q=update_q,\n            notification_q=notification_q,\n            app=live_server.app,\n            datastore=datastore\n        )\n        assert result['status'] == 'healthy', f\"Workers not healthy: {result['message']}\"\n        return result\n\n    # Attach helper methods to app for easy test access\n    live_server.app.set_workers = set_workers\n    live_server.app.check_all_workers_alive = check_all_workers_alive\n\n\n\n\n    # CRITICAL: Clean up any files from previous tests\n    # This ensures a completely clean directory\n    cleanup(datastore_path)\n\n    # CRITICAL: Reload the EXISTING datastore instead of creating a new one\n    # This keeps blueprint references valid (they capture datastore at construction)\n    # reload_state() completely resets the datastore to a clean state\n\n    # Reload state with clean data (no default watches)\n    datastore.reload_state(\n        datastore_path=datastore_path,\n        include_default_watches=False,\n        version_tag=datastore.data.get('version_tag', '0.0.0')\n    )\n    live_server.app.secret_key = init_app_secret(datastore_path)\n    logger.debug(f\"prepare_test_function: Reloaded datastore at {hex(id(datastore))}\")\n    logger.debug(f\"prepare_test_function: Path {datastore.datastore_path}\")\n\n    yield\n\n    # Cleanup: Clear watches and queue after test\n    try:\n        from changedetectionio.flask_app import update_q\n        from pathlib import Path\n\n        # Clear the queue to prevent leakage to next test\n        while not update_q.empty():\n            try:\n                update_q.get_nowait()\n            except:\n                break\n\n        datastore.data['watching'] = {}\n\n        # Delete any old watch metadata JSON files\n        base_path = Path(datastore.datastore_path).resolve()\n        max_depth = 2\n\n        for file in base_path.rglob(\"*.json\"):\n            # Calculate depth relative to base path\n            depth = len(file.relative_to(base_path).parts) - 1\n\n            if depth <= max_depth and file.is_file():\n                file.unlink()\n\n    except Exception as e:\n        logger.warning(f\"Error during datastore cleanup: {e}\")\n\n\n# So the app can also know which test name it was\n@pytest.fixture(autouse=True)\ndef set_test_name(request):\n  \"\"\"Automatically set TEST_NAME env var for every test\"\"\"\n  test_name = request.node.name\n  os.environ['PYTEST_CURRENT_TEST'] = test_name\n  yield\n  # Cleanup if needed\n\n\n@pytest.fixture(scope='session')\ndef app(request, datastore_path):\n    \"\"\"Create application once per worker (session).\n\n    Note: Actual per-test isolation is handled by:\n    - prepare_test_function() recreates datastore and cleans directory\n    - All tests on same worker use same directory (cleaned between tests)\n    \"\"\"\n    # So they don't delay in fetching\n    os.environ[\"MINIMUM_SECONDS_RECHECK_TIME\"] = \"0\"\n    logger.debug(f\"Testing with datastore_path={datastore_path}\")\n    cleanup(datastore_path)\n\n    app_config = {'datastore_path': datastore_path, 'disable_checkver' : True}\n    cleanup(app_config['datastore_path'])\n\n    logger_level = 'TRACE'\n\n    logger.remove()\n    log_level_for_stdout = { 'DEBUG', 'SUCCESS' }\n    logger.configure(handlers=[\n        {\"sink\": sys.stdout, \"level\": logger_level,\n         \"filter\" : lambda record: record['level'].name in log_level_for_stdout},\n        {\"sink\": sys.stderr, \"level\": logger_level,\n         \"filter\": lambda record: record['level'].name not in log_level_for_stdout},\n        ])\n\n    datastore = store.ChangeDetectionStore(datastore_path=app_config['datastore_path'], include_default_watches=False)\n    app = changedetection_app(app_config, datastore)\n\n    # Disable CSRF while running tests\n    app.config['WTF_CSRF_ENABLED'] = False\n    app.config['STOP_THREADS'] = True\n    # Store datastore_path so Flask routes can access it\n    app.config['TEST_DATASTORE_PATH'] = datastore_path\n\n    def teardown():\n        import threading\n        import time\n\n        # Stop all threads and services\n        datastore.stop_thread = True\n        app.config.exit.set()\n\n        # Shutdown workers gracefully before loguru cleanup\n        try:\n            from changedetectionio import worker_pool\n            worker_pool.shutdown_workers()\n        except Exception:\n            pass\n\n        # Stop socket server threads\n        try:\n            from changedetectionio.flask_app import socketio_server\n            if socketio_server and hasattr(socketio_server, 'shutdown'):\n                socketio_server.shutdown()\n        except Exception:\n            pass\n\n        # Get all active threads before cleanup\n        main_thread = threading.main_thread()\n        active_threads = [t for t in threading.enumerate() if t != main_thread and t.is_alive()]\n\n        # Wait for non-daemon threads to finish (with timeout)\n        timeout = 2.0  # 2 seconds max wait\n        start_time = time.time()\n\n        for thread in active_threads:\n            if not thread.daemon:\n                remaining_time = timeout - (time.time() - start_time)\n                if remaining_time > 0:\n                    logger.debug(f\"Waiting for non-daemon thread to finish: {thread.name}\")\n                    thread.join(timeout=remaining_time)\n                    if thread.is_alive():\n                        logger.warning(f\"Thread {thread.name} did not finish in time\")\n\n        # Give daemon threads a moment to finish their current work\n        time.sleep(0.2)\n\n        # Log any threads still running\n        remaining_threads = [t for t in threading.enumerate() if t != main_thread and t.is_alive()]\n        if remaining_threads:\n            logger.debug(f\"Threads still running after teardown: {[t.name for t in remaining_threads]}\")\n\n        # Remove all loguru handlers to prevent \"closed file\" errors\n        logger.remove()\n\n        # Cleanup files\n        cleanup(app_config['datastore_path'])\n\n       \n    request.addfinalizer(teardown)\n    yield app\n\n\n"
  },
  {
    "path": "changedetectionio/tests/custom_browser_url/__init__.py",
    "content": "# placeholder"
  },
  {
    "path": "changedetectionio/tests/custom_browser_url/test_custom_browser_url.py",
    "content": "#!/usr/bin/env python3\nimport os\n\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks\n\ndef do_test(client, live_server, make_test_use_extra_browser=False):\n\n    # Grep for this string in the logs?\n    test_url = \"https://changedetection.io/ci-test.html?non-custom-default=true\"\n    # \"non-custom-default\" should not appear in the custom browser connection\n    custom_browser_name = 'custom browser URL'\n\n    # needs to be set and something like 'ws://127.0.0.1:3000'\n    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), \"Needs PLAYWRIGHT_DRIVER_URL set for this test\"\n\n    #####################\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-empty_pages_are_a_change\": \"\",\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_webdriver\",\n              'requests-extra_browsers-0-browser_connection_url': 'ws://sockpuppetbrowser-custom-url:3000',\n              'requests-extra_browsers-0-browser_name': custom_browser_name\n              },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    if make_test_use_extra_browser:\n\n        # So the name should appear in the edit page under \"Request\" > \"Fetch Method\"\n        res = client.get(\n            url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n            follow_redirects=True\n        )\n        assert b'custom browser URL' in res.data\n\n        res = client.post(\n            url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n            data={\n                # 'run_customer_browser_url_tests.sh' will search for this string to know if we hit the right browser container or not\n                  \"url\": \"https://changedetection.io/ci-test.html?custom-browser-search-string=1\",\n                  \"tags\": \"\",\n                  \"headers\": \"\",\n                  'fetch_backend': f\"extra_browser_{custom_browser_name}\",\n                  'webdriver_js_execute_code': '',\n                  \"time_between_check_use_default\": \"y\"\n            },\n            follow_redirects=True\n        )\n\n        assert b\"Updated watch.\" in res.data\n        wait_for_all_checks(client)\n\n    # Force recheck\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b'cool it works' in res.data\n\n\n# Requires playwright to be installed\ndef test_request_via_custom_browser_url(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    # We do this so we can grep the logs of the custom container and see if the request actually went through that container\n    do_test(client, live_server, make_test_use_extra_browser=True)\n\n\ndef test_request_not_via_custom_browser_url(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    # We do this so we can grep the logs of the custom container and see if the request actually went through that container\n    do_test(client, live_server, make_test_use_extra_browser=False)\n"
  },
  {
    "path": "changedetectionio/tests/fetchers/__init__.py",
    "content": "\"\"\"Tests for the app.\"\"\"\n\n"
  },
  {
    "path": "changedetectionio/tests/fetchers/conftest.py",
    "content": "#!/usr/bin/env python3\n\nfrom .. import conftest\n"
  },
  {
    "path": "changedetectionio/tests/fetchers/test_content.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nimport os\nfrom ..util import live_server_setup, wait_for_all_checks\nimport logging\n\n\n# Requires playwright to be installed\ndef test_fetch_webdriver_content(client, live_server, measure_memory_usage, datastore_path):\n    #  live_server_setup(live_server) # Setup on conftest per function\n\n    #####################\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"application-empty_pages_are_a_change\": \"\",\n            \"requests-time_between_check-minutes\": 180,\n            'application-fetch_backend': \"html_webdriver\",\n            'application-ui-favicons_enabled': \"y\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": \"https://changedetection.io/ci-test.html\"},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    logging.getLogger().info(\"Looking for correct fetched HTML (text) from server\")\n    assert b'cool it works' in res.data\n\n    # Favicon scraper check, favicon only so far is fetched when in browser mode (not requests mode)\n    if os.getenv(\"PLAYWRIGHT_DRIVER_URL\"):\n        uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n        res = client.get(\n            url_for(\"watchlist.index\"),\n        )\n        # The UI can access it here\n        assert f'src=\"/static/favicon/{uuid}'.encode('utf8') in res.data\n\n        # Attempt to fetch it, make sure that works\n        res = client.get(url_for('static_content', group='favicon', filename=uuid))\n        assert res.status_code == 200\n        assert len(res.data) > 10\n\n        # Check the API also returns it\n        api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n        res = client.get(\n            url_for(\"watchfavicon\", uuid=uuid),\n            headers={'x-api-key': api_key}\n        )\n        assert res.status_code == 200\n        assert len(res.data) > 10\n\n    ##################### disable favicons check\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            'application-ui-favicons_enabled': \"\",\n            \"application-empty_pages_are_a_change\": \"\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n    )\n    # The UI can access it here\n    assert f'src=\"/static/favicon'.encode('utf8') not in res.data\n"
  },
  {
    "path": "changedetectionio/tests/fetchers/test_custom_js_before_content.py",
    "content": "import os\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client\n\n\ndef test_execute_custom_js(client, live_server, measure_memory_usage, datastore_path):\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), \"Needs PLAYWRIGHT_DRIVER_URL set for this test\"\n\n    test_url = url_for('test_interactive_html_endpoint', _external=True)\n    test_url = test_url.replace('localhost.localdomain', 'cdio')\n    test_url = test_url.replace('localhost', 'cdio')\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n        data={\n            \"url\": test_url,\n            \"tags\": \"\",\n            'fetch_backend': \"html_webdriver\",\n            'webdriver_js_execute_code': 'document.querySelector(\"button[name=test-button]\").click();',\n            'headers': \"testheader: yes\\buser-agent: MyCustomAgent\",\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n    wait_for_all_checks(client)\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, \"Watch history had atleast 1 (everything fetched OK)\"\n\n    assert b\"This text should be removed\" not in res.data\n\n    # Check HTML conversion detected and workd\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuid),\n        follow_redirects=True\n    )\n    assert b\"This text should be removed\" not in res.data\n    assert b\"I smell JavaScript because the button was pressed\" in res.data\n\n    assert b\"testheader: yes\" in res.data\n    assert b\"user-agent: mycustomagent\" in res.data\n\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )"
  },
  {
    "path": "changedetectionio/tests/itemprop_test_examples/README.md",
    "content": "# A list of real world examples!\n\nAlways the price should be 666.66 for our tests\n\nsee test_restock_itemprop.py::test_special_prop_examples\n\n"
  },
  {
    "path": "changedetectionio/tests/itemprop_test_examples/a.txt",
    "content": "<div class=\"PriceSection PriceSection_PriceSection__Vx1_Q PriceSection_variantHuge__P9qxg PdpPriceSection\"\n     data-testid=\"price-section\"\n     data-optly-product-tile-price-section=\"true\"><span\n        class=\"PriceRange ProductPrice variant-huge\" itemprop=\"offers\"\n        itemscope=\"\" itemtype=\"http://schema.org/Offer\"><div\n        class=\"VisuallyHidden_VisuallyHidden__VBD83\">$155.55</div><span\n        aria-hidden=\"true\" class=\"Price variant-huge\" data-testid=\"price\"\n        itemprop=\"price\"><sup class=\"sup\" data-testid=\"price-symbol\"\n                              itemprop=\"priceCurrency\" content=\"AUD\">$</sup><span\n        class=\"dollars\" data-testid=\"price-value\" itemprop=\"price\"\n        content=\"155.55\">155.55</span><span class=\"extras\"><span class=\"sup\"\n                                                              data-testid=\"price-sup\"></span></span></span></span>\n</div>\n\n<script type=\"application/ld+json\">{\n                                \"@type\": \"Product\",\n                                \"@context\": \"https://schema.org\",\n                                \"name\": \"test\",\n                                \"description\": \"test\",\n                                \"offers\": {\n                                    \"@type\": \"Offer\",\n                                    \"priceCurrency\": \"AUD\",\n                                    \"price\": 155.55\n                                },\n                            }</script>"
  },
  {
    "path": "changedetectionio/tests/plugins/test_processor.py",
    "content": "import time\n\nfrom flask import url_for\n\nfrom changedetectionio.tests.util import wait_for_all_checks\n\n\ndef test_check_plugin_processor(client, live_server, measure_memory_usage, datastore_path):\n    # requires os-int intelligence plugin installed (first basic one we test with)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'OSINT Reconnaissance' in res.data, \"Must have the OSINT plugin installed at test time\"\n    assert b'<input checked id=\"processor-0\" name=\"processor\" type=\"radio\" value=\"text_json_diff\">' in res.data, \"But the first text_json_diff processor should always be selected by default in quick watch form\"\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": 'http://127.0.0.1', \"tags\": '', 'processor': 'osint_recon'},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b'Target: http://127.0.0.1' in res.data\n    assert b'DNSKEY Records' in res.data\n    wait_for_all_checks(client)\n\n\n    # Now change it to something that doesnt exist\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    live_server.app.config['DATASTORE'].data['watching'][uuid]['processor'] = \"now_missing\"\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b\"Exception: Processor module\" in res.data and b'now_missing' in res.data, f'Should register that the plugin is missing for {uuid}'\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/__init__.py",
    "content": "\"\"\"Tests for the app.\"\"\"\n\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/conftest.py",
    "content": "#!/usr/bin/env python3\n\nfrom .. import conftest\n\n#def pytest_addoption(parser):\n#    parser.addoption(\"--url_suffix\", action=\"store\", default=\"identifier for request\")\n\n\n#def pytest_generate_tests(metafunc):\n#    # This is called for every test. Only get/set command line arguments\n#    # if the argument is specified in the list of test \"fixturenames\".\n#    option_value = metafunc.config.option.url_suffix\n#    if 'url_suffix' in metafunc.fixturenames and option_value is not None:\n#        metafunc.parametrize(\"url_suffix\", [option_value])"
  },
  {
    "path": "changedetectionio/tests/proxy_list/proxies.json-example",
    "content": "{\n  \"proxy-one\": {\n    \"label\": \"Proxy One\",\n    \"url\": \"http://squid-one:3128\"\n  },\n  \"proxy-two\": {\n    \"label\": \"Proxy Two\",\n    \"url\": \"http://squid-two:3128\"\n  }\n}\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/squid-auth.conf",
    "content": "acl localnet src 0.0.0.1-0.255.255.255  # RFC 1122 \"this\" network (LAN)\nacl localnet src 10.0.0.0/8             # RFC 1918 local private network (LAN)\nacl localnet src 100.64.0.0/10          # RFC 6598 shared address space (CGN)\nacl localnet src 169.254.0.0/16         # RFC 3927 link-local (directly plugged) machines\nacl localnet src 172.16.0.0/12          # RFC 1918 local private network (LAN)\nacl localnet src 192.168.0.0/16         # RFC 1918 local private network (LAN)\nacl localnet src fc00::/7               # RFC 4193 local private network range\nacl localnet src fe80::/10              # RFC 4291 link-local (directly plugged) machines\nacl localnet src 159.65.224.174\nacl SSL_ports port 443\nacl Safe_ports port 80          # http\nacl Safe_ports port 21          # ftp\nacl Safe_ports port 443         # https\nacl Safe_ports port 70          # gopher\nacl Safe_ports port 210         # wais\nacl Safe_ports port 1025-65535  # unregistered ports\nacl Safe_ports port 280         # http-mgmt\nacl Safe_ports port 488         # gss-http\nacl Safe_ports port 591         # filemaker\nacl Safe_ports port 777         # multiling http\nacl CONNECT method CONNECT\n\nhttp_access deny !Safe_ports\nhttp_access deny CONNECT !SSL_ports\n#http_access allow localhost manager\nhttp_access deny manager\n#http_access allow localhost\n#http_access allow localnet\n\nauth_param basic program /usr/lib/squid3/basic_ncsa_auth /etc/squid3/passwords\nauth_param basic realm proxy\nacl authenticated proxy_auth REQUIRED\nhttp_access allow authenticated\nhttp_access deny all\n\n\nhttp_port 3128\ncoredump_dir /var/spool/squid\nrefresh_pattern ^ftp:           1440    20%     10080\nrefresh_pattern ^gopher:        1440    0%      1440\nrefresh_pattern -i (/cgi-bin/|\\?) 0     0%      0\nrefresh_pattern \\/(Packages|Sources)(|\\.bz2|\\.gz|\\.xz)$ 0 0% 0 refresh-ims\nrefresh_pattern \\/Release(|\\.gpg)$ 0 0% 0 refresh-ims\nrefresh_pattern \\/InRelease$ 0 0% 0 refresh-ims\nrefresh_pattern \\/(Translation-.*)(|\\.bz2|\\.gz|\\.xz)$ 0 0% 0 refresh-ims\nrefresh_pattern .               0       20%     4320\nlogfile_rotate 0\n\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/squid-passwords.txt",
    "content": "test:$apr1$xvhFolTA$E/kz5/Rw1ewcyaSUdwqZs.\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/squid.conf",
    "content": "acl localnet src 0.0.0.1-0.255.255.255  # RFC 1122 \"this\" network (LAN)\nacl localnet src 10.0.0.0/8             # RFC 1918 local private network (LAN)\nacl localnet src 100.64.0.0/10          # RFC 6598 shared address space (CGN)\nacl localnet src 169.254.0.0/16         # RFC 3927 link-local (directly plugged) machines\nacl localnet src 172.16.0.0/12          # RFC 1918 local private network (LAN)\nacl localnet src 192.168.0.0/16         # RFC 1918 local private network (LAN)\nacl localnet src fc00::/7               # RFC 4193 local private network range\nacl localnet src fe80::/10              # RFC 4291 link-local (directly plugged) machines\nacl localnet src 159.65.224.174\nacl SSL_ports port 443\nacl Safe_ports port 80          # http\nacl Safe_ports port 21          # ftp\nacl Safe_ports port 443         # https\nacl Safe_ports port 70          # gopher\nacl Safe_ports port 210         # wais\nacl Safe_ports port 1025-65535  # unregistered ports\nacl Safe_ports port 280         # http-mgmt\nacl Safe_ports port 488         # gss-http\nacl Safe_ports port 591         # filemaker\nacl Safe_ports port 777         # multiling http\nacl CONNECT method CONNECT\n\nhttp_access deny !Safe_ports\nhttp_access deny CONNECT !SSL_ports\nhttp_access allow localhost manager\nhttp_access deny manager\nhttp_access allow localhost\nhttp_access allow localnet\nhttp_access deny all\nhttp_port 3128\ncoredump_dir /var/spool/squid\nrefresh_pattern ^ftp:           1440    20%     10080\nrefresh_pattern ^gopher:        1440    0%      1440\nrefresh_pattern -i (/cgi-bin/|\\?) 0     0%      0\nrefresh_pattern \\/(Packages|Sources)(|\\.bz2|\\.gz|\\.xz)$ 0 0% 0 refresh-ims\nrefresh_pattern \\/Release(|\\.gpg)$ 0 0% 0 refresh-ims\nrefresh_pattern \\/InRelease$ 0 0% 0 refresh-ims\nrefresh_pattern \\/(Translation-.*)(|\\.bz2|\\.gz|\\.xz)$ 0 0% 0 refresh-ims\nrefresh_pattern .               0       20%     4320\nlogfile_rotate 0\n\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/test_multiple_proxy.py",
    "content": "#!/usr/bin/env python3\n\nimport os\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks\n\n\ndef test_preferred_proxy(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    url = \"http://chosen.changedetection.io\"\n\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    wait_for_all_checks(client)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n        data={\n                \"include_filters\": \"\",\n                \"fetch_backend\": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',\n                \"headers\": \"\",\n                \"proxy\": \"proxy-two\",\n                \"tags\": \"\",\n                \"url\": url,\n                \"time_between_check_use_default\": \"y\",\n              },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n    wait_for_all_checks(client)\n    # Now the request should appear in the second-squid logs\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/test_noproxy.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client\n\n\ndef test_noproxy_option(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    # Run by run_proxy_tests.sh\n    # Call this URL then scan the containers that it never went through them\n    url = \"http://noproxy.changedetection.io\"\n\n    # Should only be available when a proxy is setup\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1))\n    assert b'No proxy' not in res.data\n\n    # Setup a proxy\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-fetch_backend\": \"html_requests\",\n            \"requests-extra_proxies-0-proxy_name\": \"custom-one-proxy\",\n            \"requests-extra_proxies-0-proxy_url\": \"http://test:awesome@squid-one:3128\",\n            \"requests-extra_proxies-1-proxy_name\": \"custom-two-proxy\",\n            \"requests-extra_proxies-1-proxy_url\": \"http://test:awesome@squid-two:3128\",\n            \"requests-extra_proxies-2-proxy_name\": \"custom-proxy\",\n            \"requests-extra_proxies-2-proxy_url\": \"http://test:awesome@squid-custom:3128\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Should be available as an option\n    res = client.get(\n        url_for(\"settings.settings_page\", unpause_on_save=1))\n    assert b'No proxy' in res.data\n\n\n    # This will add it paused\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid, unpause_on_save=1))\n    assert b'No proxy' in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid, unpause_on_save=1),\n        data={\n                \"include_filters\": \"\",\n                \"fetch_backend\": \"html_requests\",\n                \"headers\": \"\",\n                \"proxy\": \"no-proxy\",\n                \"tags\": \"\",\n                \"url\": url,\n                \"time_between_check_use_default\": \"y\",\n              },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n    wait_for_all_checks(client)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    # Now the request should NOT appear in the second-squid logs (handled by the run_test_proxies.sh script)\n\n    # Prove that it actually checked\n\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] != 0\n\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/test_proxy.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client\n\n# just make a request, we will grep in the docker logs to see it actually got called\ndef test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        # Because a URL wont show in squid/proxy logs due it being SSLed\n        # Use plain HTTP or a specific domain-name here\n        data={\"urls\": \"http://one.changedetection.io\"},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/test_proxy_noconnect.py",
    "content": "#!/usr/bin/env python3\n\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks\nimport os\nfrom ... import strtobool\n\n\n# Just to be sure the UI outputs the right error message on proxy connection failed\n# docker run -p 4444:4444 --rm --shm-size=\"2g\"  selenium/standalone-chrome:4\n# PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:3000 pytest tests/proxy_list/test_proxy_noconnect.py\n# FAST_PUPPETEER_CHROME_FETCHER=True PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:3000 pytest tests/proxy_list/test_proxy_noconnect.py\n# WEBDRIVER_URL=http://127.0.0.1:4444/wd/hub pytest tests/proxy_list/test_proxy_noconnect.py\n\ndef test_proxy_noconnect_custom(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    # Goto settings, add our custom one\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-fetch_backend\": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') or os.getenv(\"WEBDRIVER_URL\") else 'html_requests',\n            \"requests-extra_proxies-0-proxy_name\": \"custom-test-proxy\",\n            # test:awesome is set in tests/proxy_list/squid-passwords.txt\n            \"requests-extra_proxies-0-proxy_url\": \"http://127.0.0.1:3128\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    test_url = \"https://changedetection.io\"\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    options = {\n        \"url\": test_url,\n        \"fetch_backend\": \"html_webdriver\" if os.getenv('PLAYWRIGHT_DRIVER_URL') or os.getenv(\"WEBDRIVER_URL\") else \"html_requests\",\n        \"proxy\": \"ui-0custom-test-proxy\",\n        \"time_between_check_use_default\": \"y\",\n    }\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n        data=options,\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n    import time\n    wait_for_all_checks(client)\n\n    # Requests default\n    check_string = b'Cannot connect to proxy'\n\n    if os.getenv('PLAYWRIGHT_DRIVER_URL') or strtobool(os.getenv('FAST_PUPPETEER_CHROME_FETCHER', 'False')) or os.getenv(\"WEBDRIVER_URL\"):\n        check_string = b'ERR_PROXY_CONNECTION_FAILED'\n\n\n    res = client.get(url_for(\"watchlist.index\"))\n    #with open(\"/tmp/debug.html\", 'wb') as f:\n    #    f.write(res.data)\n    assert check_string in res.data\n"
  },
  {
    "path": "changedetectionio/tests/proxy_list/test_select_custom_proxy.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks\nimport os\n\n# just make a request, we will grep in the docker logs to see it actually got called\ndef test_select_custom(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    # Goto settings, add our custom one\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-fetch_backend\": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',\n            \"requests-extra_proxies-0-proxy_name\": \"custom-test-proxy\",\n            # test:awesome is set in tests/proxy_list/squid-passwords.txt\n            \"requests-extra_proxies-0-proxy_url\": \"http://test:awesome@squid-custom:3128\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        # Because a URL wont show in squid/proxy logs due it being SSLed\n        # Use plain HTTP or a specific domain-name here\n        data={\"urls\": \"https://changedetection.io/CHANGELOG.txt\"},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'Proxy Authentication Required' not in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    # We should see something via proxy\n    assert b' - 0.' in res.data\n\n    #\n    # Now we should see the request in the container logs for \"squid-squid-custom\" because it will be the only default\n\n\ndef test_custom_proxy_validation(client, live_server, measure_memory_usage, datastore_path):\n    #  live_server_setup(live_server) # Setup on conftest per function\n\n    # Goto settings, add our custom one\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-fetch_backend\": 'html_requests',\n            \"requests-extra_proxies-0-proxy_name\": \"custom-test-proxy\",\n            \"requests-extra_proxies-0-proxy_url\": \"xxxxhtt/333??p://test:awesome@squid-custom:3128\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" not in res.data\n    assert b'Proxy URLs must start with' in res.data\n\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-fetch_backend\": 'html_requests',\n            \"requests-extra_proxies-0-proxy_name\": \"custom-test-proxy\",\n            \"requests-extra_proxies-0-proxy_url\": \"https://\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" not in res.data\n    assert b\"Invalid URL.\" in res.data\n    "
  },
  {
    "path": "changedetectionio/tests/proxy_socks5/proxies.json-example",
    "content": "{\n  \"socks5proxy\": {\n    \"label\": \"socks5proxy\",\n    \"url\": \"socks5://proxy_user123:proxy_pass123@socks5proxy:1080\"\n  }\n}\n"
  },
  {
    "path": "changedetectionio/tests/proxy_socks5/proxies.json-example-noauth",
    "content": "{\n  \"socks5proxy\": {\n    \"label\": \"socks5proxy\",\n    \"url\": \"socks5://socks5proxy-noauth:1080\"\n  }\n}\n"
  },
  {
    "path": "changedetectionio/tests/proxy_socks5/test_socks5_proxy.py",
    "content": "#!/usr/bin/env python3\nimport json\nimport os\nfrom flask import url_for\nfrom changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client, delete_all_watches\n\n\ndef set_response(datastore_path):\n    import time\n    data = \"\"\"<html>\n       <body>\n     <h1>Awesome, you made it</h1>\n     yeah the socks request worked\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(data)\n    time.sleep(1)\n\ndef test_socks5(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    set_response(datastore_path)\n\n    # Setup a proxy\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-fetch_backend\": \"html_requests\",\n            # set in .github/workflows/test-only.yml\n            \"requests-extra_proxies-0-proxy_url\": \"socks5://proxy_user123:proxy_pass123@socks5proxy:1080\",\n            \"requests-extra_proxies-0-proxy_name\": \"socks5proxy\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Because the socks server should connect back to us\n    test_url = url_for('test_endpoint', _external=True) + f\"?socks-test-tag={os.getenv('SOCKSTEST', '')}\"\n    test_url = test_url.replace('localhost.localdomain', 'cdio')\n    test_url = test_url.replace('localhost', 'cdio')\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n    )\n    # check the proxy is offered as expected\n    assert b'ui-0socks5proxy' in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n        data={\n            \"include_filters\": \"\",\n            \"fetch_backend\": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',\n            \"headers\": \"\",\n            \"proxy\": \"ui-0socks5proxy\",\n            \"tags\": \"\",\n            \"url\": test_url,\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # Should see the proper string\n    assert \"Awesome, you made it\".encode('utf-8') in res.data\n\n    # PROXY CHECKER WIDGET CHECK - this needs more checking\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    res = client.get(\n        url_for(\"check_proxies.start_check\", uuid=uuid),\n        follow_redirects=True\n    )\n    # It's probably already finished super fast :(\n    #assert b\"RUNNING\" in res.data\n    \n    wait_for_all_checks(client)\n    res = client.get(\n        url_for(\"check_proxies.get_recheck_status\", uuid=uuid),\n        follow_redirects=True\n    )\n    assert b\"OK\" in res.data\n\n    delete_all_watches(client)\n\n"
  },
  {
    "path": "changedetectionio/tests/proxy_socks5/test_socks5_proxy_sources.py",
    "content": "#!/usr/bin/env python3\nimport os\nfrom flask import url_for\nfrom changedetectionio.tests.util import live_server_setup, wait_for_all_checks\n\n\ndef set_response(datastore_path):\n    import time\n    data = \"\"\"<html>\n       <body>\n     <h1>Awesome, you made it</h1>\n     yeah the socks request worked\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(data)\n    time.sleep(1)\n\n# should be proxies.json mounted from run_proxy_tests.sh already\n# -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json\ndef test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    set_response(datastore_path)\n    # Because the socks server should connect back to us\n    test_url = url_for('test_endpoint', _external=True) + f\"?socks-test-tag={os.getenv('SOCKSTEST', '')}\"\n    test_url = test_url.replace('localhost.localdomain', 'cdio')\n    test_url = test_url.replace('localhost', 'cdio')\n\n    res = client.get(url_for(\"settings.settings_page\"))\n    assert b'name=\"requests-proxy\" type=\"radio\" value=\"socks5proxy\"' in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n    )\n    # check the proxy is offered as expected\n    assert b'name=\"proxy\" type=\"radio\" value=\"socks5proxy\"' in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n        data={\n            \"include_filters\": \"\",\n            \"fetch_backend\": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',\n            \"headers\": \"\",\n            \"proxy\": \"socks5proxy\",\n            \"tags\": \"\",\n            \"url\": test_url,\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # Should see the proper string\n    assert \"Awesome, you made it\".encode('utf-8') in res.data\n"
  },
  {
    "path": "changedetectionio/tests/restock/__init__.py",
    "content": "\"\"\"Tests for the app.\"\"\"\n\n"
  },
  {
    "path": "changedetectionio/tests/restock/conftest.py",
    "content": "#!/usr/bin/env python3\n\nfrom .. import conftest\n"
  },
  {
    "path": "changedetectionio/tests/restock/test_restock.py",
    "content": "#!/usr/bin/env python3\nimport os\nimport time\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client, wait_for_notification_endpoint_output\nfrom changedetectionio.notification import (\n    default_notification_body,\n    default_notification_format,\n    default_notification_title,\n    valid_notification_formats,\n)\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n       <section id=header style=\"padding: 50px; height: 350px\">This is the header which should be ignored always - <span>add to cart</span></section>\n       <!-- stock-not-in-stock.js will ignore text in the first 300px, see elementIsInEyeBallRange(), sometimes \"add to cart\" and other junk is here -->\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div>price: $10.99</div>\n     <div id=\"sametext\">Out of stock</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\n\ndef set_back_in_stock_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div>price: $10.99</div>\n     <div id=\"sametext\">Available!</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n# Add a site in paused mode, add an invalid filter, we should still have visual selector data ready\ndef test_restock_detection(client, live_server, measure_memory_usage, datastore_path):\n\n    set_original_response(datastore_path=datastore_path)\n    #assert os.getenv('PLAYWRIGHT_DRIVER_URL'), \"Needs PLAYWRIGHT_DRIVER_URL set for this test\"\n   #  live_server_setup(live_server) # Setup on conftest per function\n    #####################\n    notification_url = url_for('test_notification_endpoint', _external=True).replace('http://localhost', 'http://changedet').replace('http', 'json')\n\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \"+default_notification_title,\n              \"application-notification_body\": \"fallback-body \"+default_notification_body,\n              \"application-notification_format\": default_notification_format,\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_webdriver\"},\n        follow_redirects=True\n    )\n    # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url\n    test_url = url_for('test_endpoint', _external=True).replace('http://localhost', 'http://changedet')\n\n\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n\n    # Is it correctly show as NOT in stock?\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'processor-restock_diff' in res.data # Should have saved in restock mode\n    assert b'not-in-stock' in res.data # should be out of stock\n\n    # Is it correctly shown as in stock\n    set_back_in_stock_response(datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'not-in-stock' not in res.data\n\n    # We should have a notification\n    notification_file = os.path.join(datastore_path, \"notification.txt\")\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n    assert os.path.isfile(notification_file), \"Notification received\"\n    os.unlink(notification_file)\n\n    # Default behaviour is to only fire notification when it goes OUT OF STOCK -> IN STOCK\n    # So here there should be no file, because we go IN STOCK -> OUT OF STOCK\n    set_original_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    time.sleep(5)\n    assert not os.path.isfile(notification_file), \"No notification should have fired when it went OUT OF STOCK by default\"\n\n    # BUT we should see that it correctly shows \"not in stock\"\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'not-in-stock' in res.data, \"Correctly showing NOT IN STOCK in the list after it changed from IN STOCK\"\n\n"
  },
  {
    "path": "changedetectionio/tests/smtp/smtp-test-server.py",
    "content": "#!/usr/bin/env python3\nimport threading\nimport time\nfrom aiosmtpd.controller import Controller\nfrom flask import Flask, Response\nfrom email import message_from_bytes\nfrom email.policy import default\n\n# Accept a SMTP message and offer a way to retrieve the last message via HTTP\n\nlast_received_message = b\"SMTP Test Server - Nothing received yet.\"\nactive_smtp_connections = 0\nsmtp_lock = threading.Lock()\n\n\nclass CustomSMTPHandler:\n    async def handle_DATA(self, server, session, envelope):\n        global last_received_message, active_smtp_connections\n\n        with smtp_lock:\n            active_smtp_connections += 1\n\n        try:\n            last_received_message = envelope.content\n            print('Receiving message from:', session.peer)\n            print('Message addressed from:', envelope.mail_from)\n            print('Message addressed to  :', envelope.rcpt_tos)\n            print('Message length        :', len(envelope.content))\n            print('*******************************')\n            print(envelope.content.decode('utf8'))\n            print('*******************************')\n\n            # Parse the email message\n            msg = message_from_bytes(envelope.content, policy=default)\n            with open('/tmp/last.eml', 'wb') as f:\n                f.write(envelope.content)\n\n            # Write parts to files based on content type\n            if msg.is_multipart():\n                for part in msg.walk():\n                    content_type = part.get_content_type()\n                    payload = part.get_payload(decode=True)\n\n                    if payload:\n                        if content_type == 'text/plain':\n                            with open('/tmp/last.txt', 'wb') as f:\n                                f.write(payload)\n                            print(f'Written text/plain part to /tmp/last.txt')\n                        elif content_type == 'text/html':\n                            with open('/tmp/last.html', 'wb') as f:\n                                f.write(payload)\n                            print(f'Written text/html part to /tmp/last.html')\n            else:\n                # Single part message\n                content_type = msg.get_content_type()\n                payload = msg.get_payload(decode=True)\n\n                if payload:\n                    if content_type == 'text/plain' or content_type.startswith('text/'):\n                        with open('/tmp/last.txt', 'wb') as f:\n                            f.write(payload)\n                        print(f'Written single part message to /tmp/last.txt')\n\n            return '250 Message accepted for delivery'\n        finally:\n            with smtp_lock:\n                active_smtp_connections -= 1\n\n\n# Simple Flask HTTP server to echo back the last SMTP message\napp = Flask(__name__)\n\n\n@app.route('/')\ndef echo_last_message():\n    global last_received_message, active_smtp_connections\n\n    # Wait for any in-progress SMTP connections to complete\n    max_wait = 5  # Maximum 5 seconds\n    wait_interval = 0.05  # Check every 50ms\n    elapsed = 0\n\n    while elapsed < max_wait:\n        with smtp_lock:\n            if active_smtp_connections == 0:\n                break\n        time.sleep(wait_interval)\n        elapsed += wait_interval\n\n    return Response(last_received_message, mimetype='text/plain')\n\n\ndef run_flask():\n    app.run(host='0.0.0.0', port=11080, debug=False, use_reloader=False)\n\n\nif __name__ == \"__main__\":\n    # Start the SMTP server\n    controller = Controller(CustomSMTPHandler(), hostname='0.0.0.0', port=11025)\n    controller.start()\n\n    # Start the HTTP server in a separate thread\n    flask_thread = threading.Thread(target=run_flask, daemon=True)\n    flask_thread.start()\n\n    # Keep the main thread alive\n    try:\n        flask_thread.join()\n    except KeyboardInterrupt:\n        print(\"Shutting down...\")\n"
  },
  {
    "path": "changedetectionio/tests/smtp/test_notification_smtp.py",
    "content": "import time\nfrom flask import url_for\nfrom email import message_from_string\nfrom email.policy import default as email_policy\n\nfrom changedetectionio.diff import HTML_REMOVED_STYLE, HTML_ADDED_STYLE, HTML_CHANGED_STYLE, REMOVED_PLACEMARKER_OPEN, \\\n    CHANGED_PLACEMARKER_OPEN, ADDED_PLACEMARKER_OPEN\nfrom changedetectionio.notification_service import NotificationContextData\nfrom changedetectionio.tests.util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, \\\n    wait_for_all_checks, \\\n    set_longer_modified_response, delete_all_watches\n\nimport logging\n\n\n# NOTE - RELIES ON mailserver as hostname running, see github build recipes\nsmtp_test_server = 'mailserver'\n\nALL_MARKUP_TOKENS = ''.join(f\"TOKEN: '{t}'\\n{{{{{t}}}}}\\n\" for t in NotificationContextData().keys())\n\nfrom changedetectionio.notification import (\n    default_notification_body,\n    default_notification_format,\n    default_notification_title,\n    valid_notification_formats,\n)\n\n\n\ndef get_last_message_from_smtp_server():\n    import requests\n    time.sleep(1) # wait for any smtp connects to die off\n    port = 11080  # HTTP server port number\n    # Make HTTP GET request to Flask server\n    response = requests.get(f'http://{smtp_test_server}:{port}/')\n    data = response.text\n    logging.info(\"get_last_message_from_smtp_server..\")\n    logging.info(data)\n    return data\n\n\n# Requires running the test SMTP server\n\ndef test_check_notification_email_formats_default_HTML(client, live_server, measure_memory_usage, datastore_path):\n    ##  live_server_setup(live_server) # Setup on conftest per function\n    set_original_response(datastore_path=datastore_path)\n\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \" + default_notification_title,\n              \"application-notification_body\": \"some text\\nfallback-body<br> \" + default_notification_body,\n              \"application-notification_format\": 'html',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Add a watch and trigger a HTTP POST\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'nice one'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    wait_for_all_checks(client)\n    set_longer_modified_response(datastore_path=datastore_path)\n    time.sleep(2)\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    time.sleep(3)\n\n    msg_raw = get_last_message_from_smtp_server()\n    assert len(msg_raw) >= 1\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(msg_raw, policy=email_policy)\n\n    # The email should have two bodies (multipart/alternative with text/plain and text/html)\n    assert msg.is_multipart()\n    assert msg.get_content_type() == 'multipart/alternative'\n\n    # Get the parts\n    parts = list(msg.iter_parts())\n    assert len(parts) == 2\n\n    # First part should be text/plain (the auto-generated plaintext version)\n    text_part = parts[0]\n    assert text_part.get_content_type() == 'text/plain'\n    text_content = text_part.get_content()\n    assert '(added) So let\\'s see what happens.\\r\\n' in text_content  # The plaintext part\n    assert 'fallback-body\\r\\n' in text_content  # The plaintext part\n\n    # Second part should be text/html\n    html_part = parts[1]\n    assert html_part.get_content_type() == 'text/html'\n    html_content = html_part.get_content()\n    assert 'some text<br>' in html_content  # We converted \\n from the notification body\n    assert 'fallback-body<br>' in html_content  # kept the original <br>\n    assert '(added) So let\\'s see what happens.<br>' in html_content  # the html part\n    delete_all_watches(client)\n\n\ndef test_check_notification_plaintext_format(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title {{watch_title}}  {{ diff_added.splitlines()[0] if diff_added else 'diff added didnt split' }}  \" + default_notification_title,\n              \"application-notification_body\": f\"some text\\n\" + default_notification_body + f\"\\nMore output test\\n{ALL_MARKUP_TOKENS}\",\n              \"application-notification_format\": 'text',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Add a watch and trigger a HTTP POST\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    time.sleep(2)\n\n    set_longer_modified_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    time.sleep(3)\n\n    msg_raw = get_last_message_from_smtp_server()\n    assert len(msg_raw) >= 1\n    #time.sleep(60)\n    # Parse the email properly using Python's email library\n    msg = message_from_string(msg_raw, policy=email_policy)\n    # Subject/title got marked up\n    subject = msg['subject']\n    # Subject should always be plaintext and never marked up to anything else\n    assert REMOVED_PLACEMARKER_OPEN not in subject\n    assert CHANGED_PLACEMARKER_OPEN not in subject\n    assert ADDED_PLACEMARKER_OPEN not in subject\n    assert 'diff added didnt split' not in subject\n    assert '(changed) Which is across' in subject\n    assert 'PLACEMARKER' not in subject\n\n    # The email should be plain text only (not multipart)\n    assert not msg.is_multipart()\n    assert msg.get_content_type() == 'text/plain'\n\n    # Get the plain text content\n    text_content = msg.get_content()\n    assert '(added) So let\\'s see what happens.\\r\\n' in text_content  # The plaintext part\n\n    # Should NOT contain HTML\n    assert '<br>' not in text_content  # We should not have HTML in plain text\n    delete_all_watches(client)\n\n\n\ndef test_check_notification_html_color_format(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title {{watch_title}} - diff_added_lines_test : '{{ diff_added.splitlines()[0] if diff_added else 'diff added didnt split' }}' \" + default_notification_title,\n              \"application-notification_body\": f\"some text\\n{default_notification_body}\\nMore output test\\n{ALL_MARKUP_TOKENS}\",\n              \"application-notification_format\": 'htmlcolor',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Add a watch and trigger a HTTP POST\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'nice one'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    wait_for_all_checks(client)\n    set_longer_modified_response(datastore_path=datastore_path)\n    time.sleep(2)\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    time.sleep(3)\n\n    msg_raw = get_last_message_from_smtp_server()\n    assert len(msg_raw) >= 1\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(msg_raw, policy=email_policy)\n    # Subject/title got marked up\n    subject = msg['subject']\n    # Subject should always be plaintext and never marked up to anything else\n    assert REMOVED_PLACEMARKER_OPEN not in subject\n    assert CHANGED_PLACEMARKER_OPEN not in subject\n    assert ADDED_PLACEMARKER_OPEN not in subject\n    assert 'diff added didnt split' not in subject\n    assert '(changed) Which is across' in subject\n    assert 'PLACEMARKER' not in subject\n    assert 'head title' in subject\n    assert \"span\" not in subject\n    assert 'background-color' not in subject\n\n\n    # The email should have two bodies (multipart/alternative with text/plain and text/html)\n    assert msg.is_multipart()\n    assert msg.get_content_type() == 'multipart/alternative'\n\n    # Get the parts\n    parts = list(msg.iter_parts())\n    assert len(parts) == 2\n\n    # First part should be text/plain (the auto-generated plaintext version)\n    text_part = parts[0]\n    assert text_part.get_content_type() == 'text/plain'\n    text_content = text_part.get_content()\n    assert 'So let\\'s see what happens.\\r\\n' in text_content  # The plaintext part\n    assert '(added)' not in text_content # Because apprise only dumb converts the html to text\n\n    # Second part should be text/html with color styling\n    html_part = parts[1]\n    assert html_part.get_content_type() == 'text/html'\n    html_content = html_part.get_content()\n    assert HTML_CHANGED_STYLE or HTML_REMOVED_STYLE in html_content\n    assert HTML_ADDED_STYLE in html_content\n    assert '&lt;' not in html_content\n\n    assert 'some text<br>' in html_content\n    delete_all_watches(client)\n\ndef test_check_notification_markdown_format(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title  diff_added_lines_test : '{{ diff_added.splitlines()[0] if diff_added else 'diff added didnt split' }}' \" + default_notification_title,\n              \"application-notification_body\": \"*header*\\n\\nsome text\\n\" + default_notification_body,\n              \"application-notification_format\": 'markdown',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Add a watch and trigger a HTTP POST\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'nice one'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    wait_for_all_checks(client)\n    set_longer_modified_response(datastore_path=datastore_path)\n    time.sleep(2)\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    time.sleep(3)\n\n    msg_raw = get_last_message_from_smtp_server()\n    assert len(msg_raw) >= 1\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(msg_raw, policy=email_policy)\n\n    # The email should have two bodies (multipart/alternative with text/plain and text/html)\n    assert msg.is_multipart()\n    assert msg.get_content_type() == 'multipart/alternative'\n    subject = msg['subject']\n    # Subject should always be plaintext and never marked up to anything else\n    assert REMOVED_PLACEMARKER_OPEN not in subject\n    assert CHANGED_PLACEMARKER_OPEN not in subject\n    assert ADDED_PLACEMARKER_OPEN not in subject\n    assert 'diff added didnt split' not in subject\n    assert '(changed) Which is across' in subject\n\n\n    # Get the parts\n    parts = list(msg.iter_parts())\n    assert len(parts) == 2\n\n    # First part should be text/plain (the auto-generated plaintext version)\n    text_part = parts[0]\n    assert text_part.get_content_type() == 'text/plain'\n    text_content = text_part.get_content()\n    # We wont see anything in the \"FALLBACK\" text but that's OK (no added/strikethrough etc)\n    assert 'So let\\'s see what happens.\\r\\n' in text_content  # The plaintext part\n\n\n    # Second part should be text/html and roughly converted from markdown to HTML\n    html_part = parts[1]\n    assert html_part.get_content_type() == 'text/html'\n    html_content = html_part.get_content()\n    assert '<p><em>header</em></p>' in html_content\n    assert '<strong>So let\\'s see what happens.</strong><br />' in html_content # Additions are <strong> in markdown\n    # the '<br />' will come from apprises conversion, not from our code, we would rather use '<br>' correctly\n    # the '<br />' is actually a nice way to know if apprise done the conversion.\n\n    delete_all_watches(client)\n\n# Custom notification body with HTML, that is either sent as HTML or rendered to plaintext and sent\ndef test_check_notification_email_formats_default_Text_override_HTML(client, live_server, measure_memory_usage, datastore_path):\n\n    # HTML problems? see this\n    # https://github.com/caronc/apprise/issues/633\n\n    set_original_response(datastore_path=datastore_path)\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n    notification_body = f\"\"\"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <title>My Webpage</title>\n</head>\n<body>\n    <h1>Test</h1>\n    {default_notification_body}\n</body>\n</html>\n\"\"\"\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \" + default_notification_title,\n              \"application-notification_body\": notification_body,\n              \"application-notification_format\": 'text',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Add a watch and trigger a HTTP POST\n    test_url = url_for('test_endpoint',content_type=\"text/html\", _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'nice one'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    #################################### FIRST SITUATION, PLAIN TEXT NOTIFICATION IS WANTED BUT WE HAVE HTML IN OUR TEMPLATE AND CONTENT ##########\n    wait_for_all_checks(client)\n    set_longer_modified_response(datastore_path=datastore_path)\n    time.sleep(2)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    time.sleep(3)\n    msg_raw = get_last_message_from_smtp_server()\n    assert len(msg_raw) >= 1\n    #    with open('/tmp/m.txt', 'w') as f:\n    #        f.write(msg_raw)\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(msg_raw, policy=email_policy)\n\n    # The email should not have two bodies, should be TEXT only\n    assert not msg.is_multipart()\n    assert msg.get_content_type() == 'text/plain'\n\n    # Get the plain text content\n    text_content = msg.get_content()\n    assert '(added) So let\\'s see what happens.\\r\\n' in text_content  # The plaintext part\n    assert '<!DOCTYPE html>' in text_content # even tho they added html, they selected plaintext so it should have not got converted\n\n\n    #################################### SECOND SITUATION, HTML IS CORRECTLY PASSED THROUGH TO THE EMAIL ####################\n    set_original_response(datastore_path=datastore_path)\n\n    # Now override as HTML format\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            \"notification_format\": 'html',\n            'fetch_backend': \"html_requests\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    time.sleep(3)\n    msg_raw = get_last_message_from_smtp_server()\n    assert len(msg_raw) >= 1\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(msg_raw, policy=email_policy)\n\n    # The email should have two bodies (multipart/alternative)\n    assert msg.is_multipart()\n    assert msg.get_content_type() == 'multipart/alternative'\n\n    # Get the parts\n    parts = list(msg.iter_parts())\n    assert len(parts) == 2\n\n    # First part should be text/plain\n    text_part = parts[0]\n    assert text_part.get_content_type() == 'text/plain'\n    text_content = text_part.get_content()\n    assert '(removed) So let\\'s see what happens.\\r\\n' in text_content  # The plaintext part\n\n    # Second part should be text/html\n    html_part = parts[1]\n    assert html_part.get_content_type() == 'text/html'\n    html_content = html_part.get_content()\n    assert '(removed) So let\\'s see what happens.' in html_content  # the html part\n    assert '&lt;!DOCTYPE html' not in html_content\n    assert '<!DOCTYPE html' in html_content # Our original template is working correctly\n\n    # https://github.com/dgtlmoon/changedetection.io/issues/2103\n    assert '<h1>Test</h1>' in html_content\n    assert '&lt;' not in html_content\n\n    delete_all_watches(client)\n\ndef test_check_plaintext_document_plaintext_notification_smtp(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"When following a plaintext document, notification in Plain Text format is sent correctly\"\"\"\n    import os\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"Some nice plain text\\nwhich we add some extra data\\nover here\\n\")\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n    notification_body = f\"\"\"{default_notification_body}\"\"\"\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \" + default_notification_title,\n              \"application-notification_body\": f\"{notification_body}\\nMore output test\\n{ALL_MARKUP_TOKENS}\",\n              \"application-notification_format\": 'text',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"text/plain\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Change the content\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"Some nice plain text\\nwhich we add some extra data\\nAnd let's talk about <title> tags\\nover here\\n\")\n\n\n    time.sleep(1)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(get_last_message_from_smtp_server(), policy=email_policy)\n\n    assert not msg.is_multipart()\n    assert msg.get_content_type() == 'text/plain'\n    body = msg.get_content()\n    # nothing is escaped, raw html stuff in text/plain\n    assert 'talk about <title> tags' in body\n    assert '(added)' in body\n    assert '<br' not in body\n    assert '&lt;' not in body\n    assert '<pre' not in body\n    delete_all_watches(client)\n\ndef test_check_plaintext_document_html_notifications(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"When following a plaintext document, notification in Plain Text format is sent correctly\"\"\"\n    import os\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"    Some nice plain text\\nwhich we add some extra data\\nover here\\n\")\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n    notification_body = f\"\"\"{default_notification_body}\"\"\"\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \" + default_notification_title,\n              \"application-notification_body\": f\"{notification_body}\\nMore output test\\n{ALL_MARKUP_TOKENS}\",\n              \"application-notification_format\": 'html',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"text/plain\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Change the content\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"    Some nice plain text\\nwhich we add some extra data\\nAnd let's talk about <title> tags\\nover here\\n\")\n\n\n    time.sleep(2)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(get_last_message_from_smtp_server(), policy=email_policy)\n\n\n    # The email should have two bodies (multipart/alternative)\n    assert msg.is_multipart()\n    assert msg.get_content_type() == 'multipart/alternative'\n\n    # Get the parts\n    parts = list(msg.iter_parts())\n    assert len(parts) == 2\n\n    # First part should be text/plain\n    text_part = parts[0]\n    assert text_part.get_content_type() == 'text/plain'\n    text_content = text_part.get_content()\n    html_part = parts[1]\n    assert html_part.get_content_type() == 'text/html'\n    html_content = html_part.get_content()\n\n\n    assert 'And let\\'s talk about <title> tags\\r\\n' in text_content\n    assert '&lt;br' not in text_content\n    assert '<span' not in text_content\n\n\n    assert 'talk about <title>' not in html_content  # the html part, should have got marked up to &lt; etc\n    assert 'talk about &lt;title&gt;' in html_content\n    # Should be the HTML, but not HTML Color\n    assert 'background-color' not in html_content\n    assert '<br>(added) And let&#39;s talk about &lt;title&gt; tags<br>' in html_content\n    assert 'PLACEMARKER' not in html_content\n    assert '&lt;br' not in html_content\n    assert '<pre role=\"article\"' in html_content # Should have got wrapped nicely in email_helpers.py\n\n    # And now for the whitespace retention\n    assert '&nbsp;&nbsp;&nbsp;&nbsp;Some nice plain text' in html_content\n    assert '(added) And let' in html_content # just to show a single whitespace didnt get touched\n    delete_all_watches(client)\n\n\ndef test_check_plaintext_document_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"When following a plaintext document, notification in Plain Text format is sent correctly\"\"\"\n    import os\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"Some nice plain text\\nwhich we add some extra data\\nover here\\n\")\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n    notification_body = f\"\"\"{default_notification_body}\"\"\"\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \" + default_notification_title,\n              \"application-notification_body\": f\"{notification_body}\\nMore output test\\n{ALL_MARKUP_TOKENS}\",\n              \"application-notification_format\": 'htmlcolor',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"text/plain\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Change the content\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"Some nice plain text\\nwhich we add some extra data\\nAnd let's talk about <title> tags\\nover here\\n\")\n\n    time.sleep(1)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(get_last_message_from_smtp_server(), policy=email_policy)\n\n    # The email should have two bodies (multipart/alternative)\n    assert msg.is_multipart()\n    assert msg.get_content_type() == 'multipart/alternative'\n\n    # Get the parts\n    parts = list(msg.iter_parts())\n    assert len(parts) == 2\n\n    # First part should be text/plain\n    text_part = parts[0]\n    assert text_part.get_content_type() == 'text/plain'\n    text_content = text_part.get_content()\n    html_part = parts[1]\n    assert html_part.get_content_type() == 'text/html'\n    html_content = html_part.get_content()\n\n\n    assert 'And let\\'s talk about <title> tags\\r\\n' in text_content\n    assert '&lt;br' not in text_content\n    assert '<span' not in text_content\n\n    assert 'talk about <title>' not in html_content  # the html part, should have got marked up to &lt; etc\n    assert 'talk about &lt;title&gt;' in html_content\n    # Should be the HTML, but not HTML Color\n    assert 'background-color' in html_content\n    assert '(added) And let' not in html_content\n    assert '&lt;br' not in html_content\n    assert '<br>' in html_content\n    assert '<pre role=\"article\"' in html_content # Should have got wrapped nicely in email_helpers.py\n    delete_all_watches(client)\n\ndef test_check_html_document_plaintext_notification(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"When following a HTML document, notification in Plain Text format is sent correctly\"\"\"\n    import os\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html><body>some stuff<br>and more stuff<br>and even more stuff<br></body></html>\")\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'\n    notification_body = f\"\"\"{default_notification_body}\"\"\"\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \" + default_notification_title,\n              \"application-notification_body\": f\"{notification_body}\\nMore output test\\n{ALL_MARKUP_TOKENS}\",\n              \"application-notification_format\": 'text',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"text/html\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html><body>sxome stuff<br>and more stuff<br>lets slip this in<br>and this in<br>and even more stuff<br>&lt;tag&gt;</body></html>\")\n\n    time.sleep(0.1)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(get_last_message_from_smtp_server(), policy=email_policy)\n\n    assert not msg.is_multipart()\n    assert msg.get_content_type() == 'text/plain'\n    body = msg.get_content()\n    assert '<tag>' in body # Should have got converted from original HTML to plaintext\n    assert '(changed) some stuff\\r\\n' in body\n    assert 'PLACEMARKER' not in body\n    assert '(into) sxome stuff\\r\\n' in body\n    assert '(added) lets slip this in\\r\\n' in body\n    assert '(added) and this in\\r\\n' in body\n    assert '&nbsp;' not in body\n\n\n    delete_all_watches(client)\n\n\ndef test_check_html_notification_with_apprise_format_is_html(client, live_server, measure_memory_usage, datastore_path):\n    ##  live_server_setup(live_server) # Setup on conftest per function\n    set_original_response(datastore_path=datastore_path)\n\n\n    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com&format=html'\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \" + default_notification_title,\n              \"application-notification_body\": \"some text\\nfallback-body<br> \" + default_notification_body,\n              \"application-notification_format\": 'html',\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Add a watch and trigger a HTTP POST\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'nice one'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    wait_for_all_checks(client)\n    set_longer_modified_response(datastore_path=datastore_path)\n    time.sleep(2)\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    time.sleep(3)\n\n    msg_raw = get_last_message_from_smtp_server()\n    assert len(msg_raw) >= 1\n\n    # Parse the email properly using Python's email library\n    msg = message_from_string(msg_raw, policy=email_policy)\n\n    # The email should have two bodies (multipart/alternative with text/plain and text/html)\n    assert msg.is_multipart()\n    assert msg.get_content_type() == 'multipart/alternative'\n\n    # Get the parts\n    parts = list(msg.iter_parts())\n    assert len(parts) == 2\n\n    # First part should be text/plain (the auto-generated plaintext version)\n    text_part = parts[0]\n    assert text_part.get_content_type() == 'text/plain'\n    text_content = text_part.get_content()\n    assert '(added) So let\\'s see what happens.\\r\\n' in text_content  # The plaintext part\n    assert 'fallback-body\\r\\n' in text_content  # The plaintext part\n\n    # Second part should be text/html\n    html_part = parts[1]\n    assert html_part.get_content_type() == 'text/html'\n    html_content = html_part.get_content()\n    assert 'some text<br>' in html_content  # We converted \\n from the notification body\n    assert 'fallback-body<br>' in html_content  # kept the original <br>\n    assert '(added) So let\\'s see what happens.<br>' in html_content  # the html part\n    delete_all_watches(client)"
  },
  {
    "path": "changedetectionio/tests/test_access_control.py",
    "content": "from .util import live_server_setup, wait_for_all_checks\nfrom flask import url_for\nimport time\n\ndef test_check_access_control(app, client, live_server, measure_memory_usage, datastore_path):\n    # Still doesnt work, but this is closer.\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    with app.test_client(use_cookies=True) as c:\n        # Check we don't have any password protection enabled yet.\n        res = c.get(url_for(\"settings.settings_page\"))\n        assert b\"Remove password\" not in res.data\n\n        # add something that we can hit via diff page later\n        res = c.post(\n            url_for(\"imports.import_page\"),\n            data={\"urls\": url_for('test_random_content_endpoint', _external=True)},\n            follow_redirects=True\n        )\n\n        assert b\"1 Imported\" in res.data\n        # causes a 'Popped wrong request context.' error when client. is accessed?\n        wait_for_all_checks(client)\n\n        res = c.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        assert b'Queued 1 watch for rechecking.' in res.data\n        wait_for_all_checks(client)\n\n\n        # Enable password check and diff page access bypass\n        res = c.post(\n            url_for(\"settings.settings_page\"),\n            data={\"application-password\": \"foobar\",\n                  \"application-shared_diff_access\": \"True\",\n                  \"requests-time_between_check-minutes\": 180,\n                  'application-fetch_backend': \"html_requests\"},\n            follow_redirects=True\n        )\n\n        assert b\"Password protection enabled.\" in res.data\n\n        # Check we hit the login\n        res = c.get(url_for(\"watchlist.index\"), follow_redirects=True)\n        # Should be logged out\n        assert b\"Login\" in res.data\n\n        # The diff page should return something valid when logged out\n        res = c.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n        assert b'Random content' in res.data\n\n        # access to assets should work (check_authentication)\n        res = c.get(url_for('static_content', group='js', filename='jquery-3.6.0.min.js'))\n        assert res.status_code == 200\n        res = c.get(url_for('static_content', group='styles', filename='styles.css'))\n        assert res.status_code == 200\n        res = c.get(url_for('static_content', group='styles', filename='404-testetest.css'))\n        assert res.status_code == 404\n\n        # Access to screenshots should be limited by 'shared_diff_access'\n        path = url_for('static_content', group='screenshot', filename='random-uuid-that-will-404.png', _external=True)\n        res = c.get(path)\n        assert res.status_code == 404\n\n        # Check wrong password does not let us in\n        res = c.post(\n            url_for(\"login\"),\n            data={\"password\": \"WRONG PASSWORD\"},\n            follow_redirects=True\n        )\n\n        assert b\"LOG OUT\" not in res.data\n        assert b\"Incorrect password\" in res.data\n\n\n        # Menu should not be available yet\n        #        assert b\"SETTINGS\" not in res.data\n        #        assert b\"BACKUP\" not in res.data\n        #        assert b\"IMPORT\" not in res.data\n\n        # defaultuser@changedetection.io is actually hardcoded for now, we only use a single password\n        res = c.post(\n            url_for(\"login\"),\n            data={\"password\": \"foobar\"},\n            follow_redirects=True\n        )\n\n        # Yes we are correctly logged in\n        assert b\"LOG OUT\" in res.data\n\n        # 598 - Password should be set and not accidently removed\n        res = c.post(\n            url_for(\"settings.settings_page\"),\n            data={\n                  \"requests-time_between_check-minutes\": 180,\n                  'application-fetch_backend': \"html_requests\"},\n            follow_redirects=True\n        )\n\n        res = c.get(url_for(\"logout\"),\n            follow_redirects=True)\n\n        assert b\"Login\" in res.data\n\n        res = c.get(url_for(\"settings.settings_page\"),\n            follow_redirects=True)\n\n\n        assert b\"Login\" in res.data\n\n        res = c.get(url_for(\"login\"))\n        assert b\"Login\" in res.data\n\n\n        res = c.post(\n            url_for(\"login\"),\n            data={\"password\": \"foobar\"},\n            follow_redirects=True\n        )\n\n        # Yes we are correctly logged in\n        assert b\"LOG OUT\" in res.data\n\n        res = c.get(url_for(\"settings.settings_page\"))\n\n        # Menu should be available now\n        assert b\"SETTINGS\" in res.data\n        assert b\"IMPORT\" in res.data\n        assert b\"LOG OUT\" in res.data\n        assert b\"time_between_check-minutes\" in res.data\n        assert b\"fetch_backend\" in res.data\n\n        ##################################################\n        # Remove password button, and check that it worked\n        ##################################################\n        res = c.post(\n            url_for(\"settings.settings_page\"),\n            data={\n                \"requests-time_between_check-minutes\": 180,\n                \"application-fetch_backend\": \"html_webdriver\",\n                \"application-removepassword_button\": \"Remove password\"\n            },\n            follow_redirects=True,\n        )\n        assert b\"Password protection removed.\" in res.data\n        assert b\"LOG OUT\" not in res.data\n\n        ############################################################\n        # Be sure a blank password doesnt setup password protection\n        ############################################################\n        res = c.post(\n            url_for(\"settings.settings_page\"),\n            data={\"application-password\": \"\",\n                  \"requests-time_between_check-minutes\": 180,\n                  'application-fetch_backend': \"html_requests\"},\n            follow_redirects=True\n        )\n\n        assert b\"Password protection enabled\" not in res.data\n\n        # Now checking the diff access\n        # Enable password check and diff page access bypass\n        res = c.post(\n            url_for(\"settings.settings_page\"),\n            data={\"application-password\": \"foobar\",\n                  # Should be disabled\n                  \"application-shared_diff_access\": \"\",\n                  \"requests-time_between_check-minutes\": 180,\n                  'application-fetch_backend': \"html_requests\"},\n            follow_redirects=True\n        )\n\n        assert b\"Password protection enabled.\" in res.data\n\n        # Check we hit the login\n        res = c.get(url_for(\"watchlist.index\"), follow_redirects=True)\n        # Should be logged out\n        assert b\"Login\" in res.data\n\n        # Access to screenshots should be limited by 'shared_diff_access'\n        res = c.get(url_for('static_content', group='screenshot', filename='random-uuid-that-will-403.png'))\n        assert res.status_code == 403\n\n        # The diff page should return something valid when logged out\n        res = c.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n        assert b'Random content' not in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_add_replace_remove_filter.py",
    "content": "#!/usr/bin/env python3\n\nimport os.path\nimport os\n\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output, delete_all_watches\nimport time\n\nfrom ..diff import ADDED_PLACEMARKER_OPEN\n\n\ndef set_original(datastore_path, excluding=None, add_line=None):\n    test_return_data = \"\"\"<html>\n     <body>\n     <p>Some initial text</p>\n     <p>So let's see what happens.</p>\n     <p>and a new line!</p>\n     <p>The golden line</p>\n     <p>A BREAK TO MAKE THE TOP LINE STAY AS \"REMOVED\" OR IT WILL GET COUNTED AS \"CHANGED INTO\"</p>\n     <p>Something irrelevant</p>          \n     </body>\n     </html>\n    \"\"\"\n\n    if add_line:\n        c=test_return_data.splitlines()\n        c.insert(5, add_line)\n        test_return_data = \"\\n\".join(c)\n\n    if excluding:\n        output = \"\"\n        for i in test_return_data.splitlines():\n            if not excluding in i:\n                output += f\"{i}\\n\"\n\n        test_return_data = output\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\ndef test_check_removed_line_contains_trigger(client, live_server, measure_memory_usage, datastore_path):\n\n    # Give the endpoint time to spin up\n    set_original(datastore_path=datastore_path)\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"trigger_text\": 'The golden line',\n              \"url\": test_url,\n              'fetch_backend': \"html_requests\",\n              'filter_text_removed': 'y',\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n    set_original(excluding='Something irrelevant', datastore_path=datastore_path)\n\n    # A line thats not the trigger should not trigger anything\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n    wait_for_all_checks(client)\n    time.sleep(0.5)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # The trigger line is REMOVED,  this should trigger\n    set_original(excluding='The golden line', datastore_path=datastore_path)\n\n    # Check in the processor here what's going on, its triggering empty-reply and no change.\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    time.sleep(1)\n\n    # Now add it back, and we should not get a trigger\n    client.get(url_for(\"ui.mark_all_viewed\"), follow_redirects=True)\n    time.sleep(0.2)\n\n    time.sleep(1)\n    set_original(excluding=None, datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    time.sleep(1)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # Remove it again, and we should get a trigger\n    set_original(excluding='The golden line', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    delete_all_watches(client)\n\n\ndef test_check_add_line_contains_trigger(client, live_server, measure_memory_usage, datastore_path):\n    \n    delete_all_watches(client)\n    time.sleep(1)\n\n    # Give the endpoint time to spin up\n    test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + \"?xxx={{ watch_url }}\"\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_title\": \"New ChangeDetection.io Notification - {{ watch_url }}\",\n              # triggered_text will contain multiple lines\n              \"application-notification_body\": 'triggered text was -{{triggered_text}}- ### 网站监测 内容更新了 ####',\n              # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation\n              \"application-notification_urls\": test_notification_url,\n              \"application-notification_format\": 'text',\n              \"application-minutes_between_check\": 180,\n              \"application-fetch_backend\": \"html_requests\"\n              },\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n    set_original(datastore_path=datastore_path)\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"trigger_text\": 'Oh yes please',\n              \"url\": test_url,\n              'processor': 'text_json_diff',\n              'fetch_backend': \"html_requests\",\n              'filter_text_removed': '',\n              'filter_text_added': 'y',\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n    set_original(excluding='Something irrelevant', datastore_path=datastore_path)\n\n    # A line thats not the trigger should not trigger anything\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # The trigger line is ADDED,  this should trigger\n    set_original(add_line='<p>Oh yes please</p>', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b'has-unread-changes' in res.data\n\n    # Takes a moment for apprise to fire\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n    assert os.path.isfile(os.path.join(datastore_path, \"notification.txt\")), \"Notification fired because I can see the output file\"\n    with open(os.path.join(datastore_path, \"notification.txt\"), 'rb') as f:\n        response = f.read()\n        assert ADDED_PLACEMARKER_OPEN.encode('utf-8') not in response #  _apply_diff_filtering shouldnt add something here\n        assert b'-Oh yes please' in response\n        assert '网站监测 内容更新了'.encode('utf-8') in response\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_api.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nimport os\n\nimport json\nimport uuid\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div id=\"sametext\">Some text thats the same</div>\n     <div id=\"changetext\">Some text that will change</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>which has this one new line</p>\n     <br>\n     So let's see what happens.  <br>\n     <div id=\"sametext\">Some text thats the same</div>\n     <div id=\"changetext\">Some text that changes</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    return None\n\ndef is_valid_uuid(val):\n    try:\n        uuid.UUID(str(val))\n        return True\n    except ValueError:\n        return False\n\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n\ndef test_api_simple(client, live_server, measure_memory_usage, datastore_path):\n\n\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Create a watch\n    set_original_response(datastore_path=datastore_path)\n\n    # Validate bad URL\n    test_url = url_for('test_endpoint', _external=True )\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"h://xxxxxxxxxom\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 400\n\n    # Create new\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url, 'tag': \"One, Two\", \"title\": \"My test URL\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert is_valid_uuid(res.json.get('uuid'))\n    watch_uuid = res.json.get('uuid')\n    assert res.status_code == 201\n\n    wait_for_all_checks(client)\n\n    # Verify its in the list and that recheck worked\n    res = client.get(\n        url_for(\"createwatch\", tag=\"OnE\"),\n        headers={'x-api-key': api_key}\n    )\n    assert watch_uuid in res.json.keys()\n    before_recheck_info = res.json[watch_uuid]\n\n    assert before_recheck_info['last_checked'] != 0\n\n    #705 `last_changed` should be zero on the first check\n    assert before_recheck_info['last_changed'] == 0\n    assert before_recheck_info['title'] == 'My test URL'\n\n    # Check the limit by tag doesnt return anything when nothing found\n    res = client.get(\n        url_for(\"createwatch\", tag=\"Something else entirely\"),\n        headers={'x-api-key': api_key}\n    )\n    assert len(res.json) == 0\n    time.sleep(2)\n    wait_for_all_checks(client)\n    set_modified_response(datastore_path=datastore_path)\n    # Trigger recheck of all ?recheck_all=1\n    res = client.get(\n        url_for(\"createwatch\", recheck_all='1'),\n        headers={'x-api-key': api_key},\n    )\n    wait_for_all_checks(client)\n\n    time.sleep(2)\n    # Did the recheck fire?\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': api_key},\n    )\n    after_recheck_info = res.json[watch_uuid]\n    assert after_recheck_info['last_checked'] != before_recheck_info['last_checked']\n    assert after_recheck_info['last_changed'] != 0\n\n    # #2877 When run in a slow fetcher like playwright etc\n    assert after_recheck_info['last_changed'] ==  after_recheck_info['last_checked']\n\n    # Check history index list\n    res = client.get(\n        url_for(\"watchhistory\", uuid=watch_uuid),\n        headers={'x-api-key': api_key},\n    )\n    watch_history = res.json\n    assert len(res.json) == 2, \"Should have two history entries (the original and the changed)\"\n\n    # Fetch a snapshot by timestamp, check the right one was found\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=watch_uuid, timestamp=list(res.json.keys())[-1]),\n        headers={'x-api-key': api_key},\n    )\n    assert b'which has this one new line' in res.data\n\n    # Fetch a snapshot by 'latest'', check the right one was found\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=watch_uuid, timestamp='latest'),\n        headers={'x-api-key': api_key},\n    )\n    assert b'which has this one new line' in res.data\n    assert b'<div id' not in res.data\n\n    # Fetch the HTML of the latest one\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=watch_uuid, timestamp='latest')+\"?html=1\",\n        headers={'x-api-key': api_key},\n    )\n    assert b'which has this one new line' in res.data\n    assert b'<div id' in res.data\n\n\n    # Fetch the difference between two versions (default text format)\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest'),\n        headers={'x-api-key': api_key},\n    )\n    assert b'(changed) Which is across' in res.data\n    assert b'Some text thats the same' in res.data\n\n    # Fetch the difference between two versions (default text format)\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+\"?changesOnly=true\",\n        headers={'x-api-key': api_key},\n    )\n    assert b'Some text thats the same' not in res.data\n\n    # Test htmlcolor format\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?format=htmlcolor',\n        headers={'x-api-key': api_key},\n    )\n    assert b'aria-label=\"Changed text\" title=\"Changed text\">Which is across multiple lines' in res.data\n\n    # Test html format\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?format=html',\n        headers={'x-api-key': api_key},\n    )\n    assert res.status_code == 200\n    assert b'<br>' in res.data\n\n    # Test markdown format\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?format=markdown',\n        headers={'x-api-key': api_key},\n    )\n    assert res.status_code == 200\n\n    # Test new diff preference parameters\n    # Test removed=false (should hide removed content)\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?removed=false',\n        headers={'x-api-key': api_key},\n    )\n    # Should not contain removed content indicator\n    assert b'(removed)' not in res.data\n    # Should still contain added content\n    assert b'(added)' in res.data or b'which has this one new line' in res.data\n\n    # Test added=false (should hide added content)\n    # Note: The test data has replacements, not pure additions, so we test differently\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?added=false&replaced=false',\n        headers={'x-api-key': api_key},\n    )\n    # With both added and replaced disabled, should have minimal content\n    # Should not contain added indicators\n    assert b'(added)' not in res.data\n\n    # Test replaced=false (should hide replaced/changed content)\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?replaced=false',\n        headers={'x-api-key': api_key},\n    )\n    # Should not contain changed content indicator\n    assert b'(changed)' not in res.data\n\n    # Test type=diffWords for word-level diff\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?type=diffWords&format=htmlcolor',\n        headers={'x-api-key': api_key},\n    )\n    # Should contain HTML formatted diff\n    assert res.status_code == 200\n    assert len(res.data) > 0\n\n    # Test combined parameters: show only additions with word diff\n    res = client.get(\n        url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp='previous', to_timestamp='latest')+'?removed=false&replaced=false&type=diffWords',\n        headers={'x-api-key': api_key},\n    )\n    assert res.status_code == 200\n    # Should not contain removed or changed markers\n    assert b'(removed)' not in res.data\n    assert b'(changed)' not in res.data\n\n\n    # Fetch the whole watch\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    watch = res.json\n    # @todo how to handle None/default global values?\n    assert watch['history_n'] == 2, \"Found replacement history section, which is in its own API\"\n\n    assert watch.get('viewed') == False\n    # Loading the most recent snapshot should force viewed to become true\n    client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"), follow_redirects=True)\n\n    time.sleep(3)\n    # Fetch the whole watch again, viewed should be true\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    watch = res.json\n    assert watch.get('viewed') == True\n\n    # basic systeminfo check\n    res = client.get(\n        url_for(\"systeminfo\"),\n        headers={'x-api-key': api_key},\n    )\n    assert res.json.get('watch_count') == 1\n    assert res.json.get('uptime') > 0.5\n\n    ######################################################\n    # Mute and Pause, check it worked\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid, paused='paused'),\n        headers={'x-api-key': api_key}\n    )\n    assert b'OK' in res.data\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid,  muted='muted'),\n        headers={'x-api-key': api_key}\n    )\n    assert b'OK' in res.data\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.json.get('paused') == True\n    assert res.json.get('notification_muted') == True\n\n    # Now unpause, unmute\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid,  muted='unmuted'),\n        headers={'x-api-key': api_key}\n    )\n    assert b'OK' in res.data\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid, paused='unpaused'),\n        headers={'x-api-key': api_key}\n    )\n    assert b'OK' in res.data\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.json.get('paused') == 0\n    assert res.json.get('notification_muted') == 0\n    ######################################################\n\n\n\n\n\n    # Finally delete the watch\n    res = client.delete(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key},\n    )\n    assert res.status_code == 204\n\n    # Check via a relist\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': api_key}\n    )\n    assert len(res.json) == 0, \"Watch list should be empty\"\n\ndef test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test the full round trip, this way we test the default Model fits back into OpenAPI spec\n    :param client:\n    :param live_server:\n    :param measure_memory_usage:\n    :param datastore_path:\n    :return:\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create new\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 201\n    uuid = res.json.get('uuid')\n\n    # Now fetch it and send it back\n\n    res = client.get(\n        url_for(\"watch\", uuid=uuid),\n        headers={'x-api-key': api_key}\n    )\n\n    watch=res.json\n\n    # Be sure that 'readOnly' values are never updated in the real watch\n    watch['last_changed'] = 454444444444\n    watch['date_created'] = 454444444444\n\n    # HTTP PUT ( UPDATE an existing watch )\n    res = client.put(\n        url_for(\"watch\", uuid=uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps(watch),\n    )\n    if res.status_code != 200:\n        print(f\"\\n=== PUT failed with {res.status_code} ===\")\n        print(f\"Error: {res.data}\")\n    assert res.status_code == 200, \"HTTP PUT update was sent OK\"\n\n    res = client.get(\n        url_for(\"watch\", uuid=uuid),\n        headers={'x-api-key': api_key}\n    )\n    last_changed = res.json.get('last_changed')\n    assert last_changed != 454444444444\n    assert last_changed != \"454444444444\"\n\n    date_created = res.json.get('date_created')\n    assert date_created != 454444444444\n    assert date_created != \"454444444444\"\n\n\ndef test_access_denied(client, live_server, measure_memory_usage, datastore_path):\n    # `config_api_token_enabled` Should be On by default\n    res = client.get(\n        url_for(\"createwatch\")\n    )\n    assert res.status_code == 403\n\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': \"something horrible\"}\n    )\n    assert res.status_code == 403\n\n    # Disable config_api_token_enabled and it should work\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-fetch_backend\": \"html_requests\",\n            \"application-api_access_token_enabled\": \"\"\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    res = client.get(\n        url_for(\"createwatch\")\n    )\n    assert res.status_code == 200\n\n    # Cleanup everything\n    delete_all_watches(client)\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-fetch_backend\": \"html_requests\",\n            \"application-api_access_token_enabled\": \"y\"\n        },\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\ndef test_api_watch_PUT_update(client, live_server, measure_memory_usage, datastore_path):\n\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    # Create a watch\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create new\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url,\n                         'tag': \"One, Two\",\n                         \"title\": \"My test URL\",\n                         'headers': {'cookie': 'yum'},\n                         \"conditions\": [\n                             {\n                                 \"field\": \"page_filtered_text\",\n                                 \"operator\": \"contains_regex\",\n                                 \"value\": \".\"  # contains anything\n                             }\n                         ],\n                         \"conditions_match_logic\": \"ALL\",\n                         }\n                        ),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    if res.status_code != 201:\n        print(f\"\\n=== POST createwatch failed with {res.status_code} ===\")\n        print(f\"Response: {res.data}\")\n    assert res.status_code == 201\n\n    wait_for_all_checks(client)\n    # Get a listing, it will be the first one\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': api_key}\n    )\n\n    watch_uuid = list(res.json.keys())[0]\n    assert not res.json[watch_uuid].get('viewed'), 'A newly created watch can only be unviewed'\n\n    # Check in the edit page just to be sure\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=watch_uuid),\n    )\n    assert b\"cookie: yum\" in res.data, \"'cookie: yum' found in 'headers' section\"\n    assert b\"One\" in res.data, \"Tag 'One' was found\"\n    assert b\"Two\" in res.data, \"Tag 'Two' was found\"\n\n    # HTTP PUT ( UPDATE an existing watch )\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\n            \"title\": \"new title\",\n            'time_between_check': {'minutes': 552},\n            'headers': {'cookie': 'all eaten'},\n            'last_viewed': int(time.time())\n        }),\n    )\n    assert res.status_code == 200, \"HTTP PUT update was sent OK\"\n\n    # HTTP GET single watch, title should be updated\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.json.get('title') == 'new title'\n    assert res.json.get('viewed'), 'With the timestamp greater than \"changed\" a watch can be updated to viewed'\n\n    # Check in the edit page just to be sure\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=watch_uuid),\n    )\n    assert b\"new title\" in res.data, \"new title found in edit page\"\n    assert b\"552\" in res.data, \"552 minutes found in edit page\"\n    assert b\"One\" in res.data, \"Tag 'One' was found\"\n    assert b\"Two\" in res.data, \"Tag 'Two' was found\"\n    assert b\"cookie: all eaten\" in res.data, \"'cookie: all eaten' found in 'headers' section\"\n\n    ######################################################\n\n    # HTTP PUT try a field that doesn't exist\n\n    # HTTP PUT an update\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"title\": \"new title\", \"some other field\": \"uh oh\"}),\n    )\n\n    assert res.status_code == 400, \"Should get error 400 when we give a field that doesnt exist\"\n    # Backend validation now rejects unknown fields with a clear error message\n    assert (b'Unknown field' in res.data or\n            b'Additional properties are not allowed' in res.data or\n            b'Unevaluated properties are not allowed' in res.data or\n            b'does not match any of the regexes' in res.data), \\\n            \"Should reject unknown fields with validation error\"\n\n\n    # Try a XSS URL\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\n            'url': 'javascript:alert(document.domain)'\n        }),\n    )\n    assert res.status_code == 400\n\n    # Cleanup everything\n    delete_all_watches(client)\n\n\ndef test_api_import(client, live_server, measure_memory_usage, datastore_path):\n\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Test 1: Basic import with tag\n    res = client.post(\n        url_for(\"import\") + \"?tag=import-test\",\n        data='https://website1.com\\r\\nhttps://website2.com',\n        # We removed  'content-type': 'text/plain', the Import API should assume this if none is set #3547 #3542\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n    assert len(res.json) == 2\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b\"https://website1.com\" in res.data\n    assert b\"https://website2.com\" in res.data\n\n    # Should see the new tag in the tag/groups list\n    res = client.get(url_for('tags.tags_overview_page'))\n    assert b'import-test' in res.data\n\n    # Test 2: Import with watch configuration fields (issue #3845)\n    # Test string field (include_filters), boolean (paused), and processor\n    import urllib.parse\n    params = urllib.parse.urlencode({\n        'tag': 'config-test',\n        'include_filters': 'div.content',\n        'paused': 'true',\n        'processor': 'text_json_diff',\n        'title': 'Imported with Config'\n    })\n\n    res = client.post(\n        url_for(\"import\") + \"?\" + params,\n        data='https://website3.com',\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n    assert len(res.json) == 1\n    uuid = res.json[0]\n\n    # Verify the configuration was applied\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    assert watch['include_filters'] == ['div.content'], \"include_filters should be set as array\"\n    assert watch['paused'] == True, \"paused should be True\"\n    assert watch['processor'] == 'text_json_diff', \"processor should be set\"\n    assert watch['title'] == 'Imported with Config', \"title should be set\"\n\n    # Test 3: Import with array field (notification_urls) - using valid Apprise format\n    params = urllib.parse.urlencode({\n        'tag': 'notification-test',\n        'notification_urls': 'mailto://test@example.com,mailto://admin@example.com'\n    })\n\n    res = client.post(\n        url_for(\"import\") + \"?\" + params,\n        data='https://website4.com',\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n    uuid = res.json[0]\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    assert isinstance(watch['notification_urls'], list), \"notification_urls must be stored as a list\"\n    assert len(watch['notification_urls']) == 2, \"notification_urls should have 2 entries\"\n    assert 'mailto://test@example.com' in watch['notification_urls'], \"notification_urls should contain first email\"\n    assert 'mailto://admin@example.com' in watch['notification_urls'], \"notification_urls should contain second email\"\n\n    # Test 4: Import with object field (time_between_check)\n    import json\n    time_config = json.dumps({\"hours\": 2, \"minutes\": 30})\n    params = urllib.parse.urlencode({\n        'tag': 'schedule-test',\n        'time_between_check': time_config\n    })\n\n    res = client.post(\n        url_for(\"import\") + \"?\" + params,\n        data='https://website5.com',\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n    uuid = res.json[0]\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    assert watch['time_between_check']['hours'] == 2, \"time_between_check hours should be 2\"\n    assert watch['time_between_check']['minutes'] == 30, \"time_between_check minutes should be 30\"\n\n    # Test 5: Import with invalid processor (should fail)\n    res = client.post(\n        url_for(\"import\") + \"?processor=invalid_processor\",\n        data='https://website6.com',\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 400, \"Should reject invalid processor\"\n    assert b\"Invalid processor\" in res.data, \"Error message should mention invalid processor\"\n\n    # Test 6: Import with invalid field (should fail)\n    res = client.post(\n        url_for(\"import\") + \"?unknown_field=value\",\n        data='https://website7.com',\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 400, \"Should reject unknown field\"\n    assert b\"Unknown watch configuration parameter\" in res.data, \"Error message should mention unknown parameter\"\n\n    # Test 7: Import with complex nested array (browser_steps) - array of objects\n    browser_steps = json.dumps([\n        {\"operation\": \"wait\", \"selector\": \"5\", \"optional_value\": \"\"},\n        {\"operation\": \"click\", \"selector\": \"button.submit\", \"optional_value\": \"\"}\n    ])\n    params = urllib.parse.urlencode({\n        'tag': 'browser-test',\n        'browser_steps': browser_steps\n    })\n\n    res = client.post(\n        url_for(\"import\") + \"?\" + params,\n        data='https://website8.com',\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200, \"Should accept browser_steps array\"\n    uuid = res.json[0]\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    assert len(watch['browser_steps']) == 2, \"Should have 2 browser steps\"\n    assert watch['browser_steps'][0]['operation'] == 'wait', \"First step should be wait\"\n    assert watch['browser_steps'][1]['operation'] == 'click', \"Second step should be click\"\n    assert watch['browser_steps'][1]['selector'] == 'button.submit', \"Second step selector should be button.submit\"\n\n    # Cleanup\n    delete_all_watches(client)\n\n\ndef test_api_import_small_synchronous(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that small imports (< threshold) are processed synchronously\"\"\"\n    from changedetectionio.api.Import import IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD\n\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Use local test endpoint to avoid network delays\n    test_url_base = url_for('test_endpoint', _external=True)\n\n    # Create URLs: threshold - 1 to stay under limit\n    num_urls = min(5, IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD - 1)  # Use small number for faster test\n    urls = '\\n'.join([f'{test_url_base}?id=small-{i}' for i in range(num_urls)])\n\n    # Import small batch\n    res = client.post(\n        url_for(\"import\") + \"?tag=small-test\",\n        data=urls,\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    # Should return 200 OK with UUID list (synchronous)\n    assert res.status_code == 200, f\"Should return 200 for small imports, got {res.status_code}\"\n    assert isinstance(res.json, list), \"Response should be a list of UUIDs\"\n    assert len(res.json) == num_urls, f\"Should return {num_urls} UUIDs, got {len(res.json)}\"\n\n    # Verify all watches were created immediately\n    for uuid in res.json:\n        assert uuid in live_server.app.config['DATASTORE'].data['watching'], \\\n            f\"Watch {uuid} should exist immediately after synchronous import\"\n\n    print(f\"\\n✓ Successfully created {num_urls} watches synchronously\")\n\n\ndef test_api_import_large_background(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that large imports (>= threshold) are processed in background thread\"\"\"\n    from changedetectionio.api.Import import IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD\n    import time\n\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Use local test endpoint to avoid network delays\n    test_url_base = url_for('test_endpoint', _external=True)\n\n    # Create URLs: threshold + 10 to trigger background processing\n    num_urls = IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD + 10\n    urls = '\\n'.join([f'{test_url_base}?id=bulk-{i}' for i in range(num_urls)])\n\n    # Import large batch\n    res = client.post(\n        url_for(\"import\") + \"?tag=bulk-test\",\n        data=urls,\n        headers={'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    # Should return 202 Accepted (background processing)\n    assert res.status_code == 202, f\"Should return 202 for large imports, got {res.status_code}\"\n    assert b\"background\" in res.data.lower(), \"Response should mention background processing\"\n\n    # Extract expected count from response\n    response_json = res.json\n    assert 'count' in response_json, \"Response should include count\"\n    assert response_json['count'] == num_urls, f\"Count should be {num_urls}, got {response_json['count']}\"\n\n    # Wait for background thread to complete (with timeout)\n    max_wait = 10  # seconds\n    wait_interval = 0.5\n    elapsed = 0\n    watches_created = 0\n\n    while elapsed < max_wait:\n        time.sleep(wait_interval)\n        elapsed += wait_interval\n\n        # Count how many watches have been created\n        watches_created = len([\n            uuid for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items()\n            if 'id=bulk-' in watch['url']\n        ])\n\n        if watches_created == num_urls:\n            break\n\n    # Verify all watches were created\n    assert watches_created == num_urls, \\\n        f\"Expected {num_urls} watches to be created, but found {watches_created} after {elapsed}s\"\n\n    # Verify watches have correct configuration\n    bulk_watches = [\n        watch for watch in live_server.app.config['DATASTORE'].data['watching'].values()\n        if 'id=bulk-' in watch['url']\n    ]\n\n    assert len(bulk_watches) == num_urls, \"All bulk watches should exist\"\n\n    # Check that they have the correct tag\n    datastore = live_server.app.config['DATASTORE']\n    # Get UUIDs of bulk watches by filtering the datastore keys\n    bulk_watch_uuids = [\n        uuid for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items()\n        if 'id=bulk-' in watch['url']\n    ]\n    for watch_uuid in bulk_watch_uuids:\n        tags = datastore.get_all_tags_for_watch(uuid=watch_uuid)\n        tag_names = [t['title'] for t in tags.values()]\n        assert 'bulk-test' in tag_names, f\"Watch {watch_uuid} should have 'bulk-test' tag\"\n\n    print(f\"\\n✓ Successfully created {num_urls} watches in background (took {elapsed}s)\")\n\n\ndef test_api_restock_processor_config(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that processor_config_restock_diff is accepted by the API for watches using\n    restock_diff processor, that its schema is validated (enum values, types), and that\n    genuinely unknown fields are rejected with an error that originates from the\n    OpenAPI spec validation layer.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create a watch in restock_diff mode WITH processor_config in the POST body (matches the API docs example)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"processor\": \"restock_diff\",\n            \"title\": \"Restock test\",\n            \"processor_config_restock_diff\": {\n                \"in_stock_processing\": \"in_stock_only\",\n                \"follow_price_changes\": True,\n                \"price_change_min\": 8888888.0,\n            }\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 201\n    watch_uuid = res.json.get('uuid')\n    assert is_valid_uuid(watch_uuid)\n\n    # Verify the value set on POST is reflected in the UI edit page (not just via PUT)\n    res = client.get(url_for(\"ui.ui_edit.edit_page\", uuid=watch_uuid))\n    assert res.status_code == 200\n    assert b'8888888' in res.data, \"price_change_min set via POST should appear in the UI edit form\"\n\n    # Valid processor_config_restock_diff update via PUT should also be accepted\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\n            \"processor_config_restock_diff\": {\n                \"in_stock_processing\": \"all_changes\",\n                \"follow_price_changes\": False,\n                \"price_change_min\": 8888888.0,\n                \"price_change_max\": 9999999.0,\n            }\n        }),\n    )\n    assert res.status_code == 200, f\"Valid processor_config_restock_diff should be accepted, got: {res.data}\"\n\n    # Verify the updated value is still reflected in the UI edit page\n    res = client.get(url_for(\"ui.ui_edit.edit_page\", uuid=watch_uuid))\n    assert res.status_code == 200\n    assert b'8888888' in res.data, \"price_change_min set via PUT should appear in the UI edit form\"\n\n    # An invalid enum value inside processor_config_restock_diff should be rejected by the spec\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\n            \"processor_config_restock_diff\": {\n                \"in_stock_processing\": \"not_a_valid_enum_value\"\n            }\n        }),\n    )\n    assert res.status_code == 400, \"Invalid enum value in processor config should be rejected\"\n    assert b'Validation failed' in res.data, \"Rejection should come from OpenAPI spec validation layer\"\n\n    # A completely unknown field should be rejected (either by OpenAPI spec validation or\n    # the application-level field filter — both are acceptable gatekeepers)\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"field_that_is_not_in_the_spec_at_all\": \"some value\"}),\n    )\n    assert res.status_code == 400, \"Unknown fields should be rejected\"\n    assert (b'Validation failed' in res.data or b'Unknown field' in res.data), \\\n        \"Rejection should come from either the OpenAPI spec validation layer or application field filter\"\n\n    delete_all_watches(client)\n\n\ndef test_api_conflict_UI_password(client, live_server, measure_memory_usage, datastore_path):\n\n\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Enable password check and diff page access bypass\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-password\": \"foobar\", # password is now set! API should still work!\n              \"application-api_access_token_enabled\": \"y\",\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Password protection enabled.\" in res.data\n\n    # Create a watch\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create new\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url, \"title\": \"My test URL\" }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 201\n\n\n    wait_for_all_checks(client)\n    url = url_for(\"createwatch\")\n    # Get a listing, it will be the first one\n    res = client.get(\n        url,\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n\n    assert len(res.json)\n\n\ndef test_api_url_validation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test URL validation for edge cases in both CREATE and UPDATE endpoints.\n    Addresses security issues where empty/null/invalid URLs could bypass validation.\n\n    This test ensures that:\n    - CREATE endpoint rejects null, empty, and invalid URLs\n    - UPDATE endpoint rejects attempts to change URL to null, empty, or invalid\n    - UPDATE endpoint allows updating other fields without touching URL\n    - URL validation properly checks protocol, format, and safety\n    \"\"\"\n\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: CREATE with null URL should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": None}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 400, \"Creating watch with null URL should fail\"\n\n    # Test 2: CREATE with empty string URL should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 400, \"Creating watch with empty string URL should fail\"\n    assert b'Invalid or unsupported URL' in res.data or b'required' in res.data.lower()\n\n    # Test 3: CREATE with whitespace-only URL should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"   \"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 400, \"Creating watch with whitespace-only URL should fail\"\n\n    # Test 4: CREATE with invalid protocol should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"javascript:alert(1)\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 400, \"Creating watch with javascript: protocol should fail\"\n\n    # Test 5: CREATE with missing protocol should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"example.com\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 400, \"Creating watch without protocol should fail\"\n\n    # Test 6: CREATE with valid URL should succeed (baseline)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url, \"title\": \"Valid URL test\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 201, \"Creating watch with valid URL should succeed\"\n    assert is_valid_uuid(res.json.get('uuid'))\n    watch_uuid = res.json.get('uuid')\n    wait_for_all_checks(client)\n\n    # Test 7: UPDATE to null URL should fail\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"url\": None}),\n    )\n    assert res.status_code == 400, \"Updating watch URL to null should fail\"\n    # Accept either OpenAPI validation error or our custom validation error\n    assert (b'URL cannot be null' in res.data or\n            b'Validation failed' in res.data or\n            b'validation error' in res.data.lower())\n\n    # Test 8: UPDATE to empty string URL should fail\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"url\": \"\"}),\n    )\n    assert res.status_code == 400, \"Updating watch URL to empty string should fail\"\n    # Accept either our custom validation error or OpenAPI/schema validation error\n    assert b'URL cannot be empty' in res.data or b'OpenAPI validation' in res.data or b'Invalid or unsupported URL' in res.data\n\n    # Test 9: UPDATE to whitespace-only URL should fail\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"url\": \"   \\t\\n  \"}),\n    )\n    assert res.status_code == 400, \"Updating watch URL to whitespace should fail\"\n    # Accept either our custom validation error or generic validation error\n    assert b'URL cannot be empty' in res.data or b'Invalid or unsupported URL' in res.data or b'validation' in res.data.lower()\n\n    # Test 10: UPDATE to invalid protocol should fail (javascript:)\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"url\": \"javascript:alert(document.domain)\"}),\n    )\n    assert res.status_code == 400, \"Updating watch URL to XSS attempt should fail\"\n    assert b'Invalid or unsupported URL' in res.data or b'protocol' in res.data.lower()\n\n    # Test 11: UPDATE to file:// protocol should fail (unless ALLOW_FILE_URI is set)\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"url\": \"file:///etc/passwd\"}),\n    )\n    assert res.status_code == 400, \"Updating watch URL to file:// should fail by default\"\n\n    # Test 12: UPDATE other fields without URL should succeed\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"title\": \"Updated title without URL change\"}),\n    )\n    assert res.status_code == 200, \"Updating other fields without URL should succeed\"\n\n    # Test 13: Verify URL is still valid after non-URL update\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.json.get('url') == test_url, \"URL should remain unchanged\"\n    assert res.json.get('title') == \"Updated title without URL change\"\n\n    # Test 14: UPDATE to valid different URL should succeed\n    new_valid_url = test_url + \"?new=param\"\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"url\": new_valid_url}),\n    )\n    assert res.status_code == 200, \"Updating to valid different URL should succeed\"\n\n    # Test 15: Verify URL was actually updated\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.json.get('url') == new_valid_url, \"URL should be updated to new valid URL\"\n\n    # Test 16: CREATE with XSS in URL parameters should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"http://example.com?xss=<script>alert(1)</script>\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    # This should fail because of suspicious characters check\n    assert res.status_code == 400, \"Creating watch with XSS in URL params should fail\"\n\n    # Cleanup\n    client.delete(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key},\n    )\n    delete_all_watches(client)\n\n\ndef test_api_time_between_check_validation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that time_between_check validation works correctly:\n    - When time_between_check_use_default is false, at least one time value must be > 0\n    - Values must be valid integers\n    \"\"\"\n    import json\n    from flask import url_for\n    \n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    \n    # Test 1: time_between_check_use_default=false with NO time_between_check should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example.com\",\n            \"time_between_check_use_default\": False\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 400, \"Should fail when time_between_check_use_default=false with no time_between_check\"\n    assert b\"At least one time interval\" in res.data, \"Error message should mention time interval requirement\"\n    \n    # Test 2: time_between_check_use_default=false with ALL zeros should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example.com\",\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\n                \"weeks\": 0,\n                \"days\": 0,\n                \"hours\": 0,\n                \"minutes\": 0,\n                \"seconds\": 0\n            }\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 400, \"Should fail when all time values are 0\"\n    assert b\"At least one time interval\" in res.data, \"Error message should mention time interval requirement\"\n    \n    # Test 3: time_between_check_use_default=false with NULL values should fail\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example.com\",\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\n                \"weeks\": None,\n                \"days\": None,\n                \"hours\": None,\n                \"minutes\": None,\n                \"seconds\": None\n            }\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 400, \"Should fail when all time values are null\"\n    assert b\"At least one time interval\" in res.data, \"Error message should mention time interval requirement\"\n    \n    # Test 4: time_between_check_use_default=false with valid hours should succeed\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example.com\",\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\n                \"hours\": 2\n            }\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 201, \"Should succeed with valid hours value\"\n    uuid1 = res.json.get('uuid')\n    \n    # Test 5: time_between_check_use_default=false with valid minutes should succeed\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example2.com\",\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\n                \"minutes\": 30\n            }\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 201, \"Should succeed with valid minutes value\"\n    uuid2 = res.json.get('uuid')\n    \n    # Test 6: time_between_check_use_default=true (or missing) with no time_between_check should succeed (uses defaults)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example3.com\",\n            \"time_between_check_use_default\": True\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 201, \"Should succeed when using default settings\"\n    uuid3 = res.json.get('uuid')\n    \n    # Test 7: Default behavior (no time_between_check_use_default field) should use defaults and succeed\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example4.com\"\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 201, \"Should succeed with default behavior (using global settings)\"\n    uuid4 = res.json.get('uuid')\n    \n    # Test 8: Verify integer type validation - string should fail (OpenAPI validation)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example5.com\",\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\n                \"hours\": \"not_a_number\"\n            }\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 400, \"Should fail when time value is not an integer\"\n    assert b\"Validation failed\" in res.data or b\"not of type\" in res.data, \"Should mention validation/type error\"\n    \n    # Cleanup\n    for uuid in [uuid1, uuid2, uuid3, uuid4]:\n        client.delete(\n            url_for(\"watch\", uuid=uuid),\n            headers={'x-api-key': api_key},\n        )\n"
  },
  {
    "path": "changedetectionio/tests/test_api_notification_urls_validation.py",
    "content": "#!/usr/bin/env python3\n\n\"\"\"\nTest notification_urls validation in Watch and Tag API endpoints.\nEnsures that invalid AppRise URLs are rejected when setting notification_urls.\n\nValid AppRise notification URLs use specific protocols like:\n- posts://example.com - POST to HTTP endpoint\n- gets://example.com - GET to HTTP endpoint\n- mailto://user@example.com - Email\n- slack://token/channel - Slack\n- discord://webhook_id/webhook_token - Discord\n- etc.\n\nInvalid notification URLs:\n- https://example.com - Plain HTTPS is NOT a valid AppRise notification protocol\n- ftp://example.com - FTP is NOT a valid AppRise notification protocol\n- Plain URLs without proper AppRise protocol prefix\n\"\"\"\n\nfrom flask import url_for\nimport json\n\n\ndef test_watch_notification_urls_validation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that Watch PUT/POST endpoints validate notification_urls.\"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Test 1: Create a watch with valid notification URLs\n    valid_urls = [\"posts://example.com/notify1\", \"posts://example.com/notify2\"]\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example.com\",\n            \"notification_urls\": valid_urls\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 201, \"Should accept valid notification URLs on watch creation\"\n    watch_uuid = res.json['uuid']\n\n    # Verify the notification URLs were saved\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert set(res.json['notification_urls']) == set(valid_urls), \"Valid notification URLs should be saved\"\n\n    # Test 2: Try to create a watch with invalid notification URLs (https:// is not valid)\n    invalid_urls = [\"https://example.com/webhook\"]\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example.com\",\n            \"notification_urls\": invalid_urls\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400, \"Should reject https:// notification URLs (not a valid AppRise protocol)\"\n    assert b\"is not a valid AppRise URL\" in res.data, \"Should provide AppRise validation error message\"\n\n    # Test 2b: Also test other invalid protocols\n    invalid_urls_ftp = [\"ftp://not-apprise-url\"]\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": \"https://example.com\",\n            \"notification_urls\": invalid_urls_ftp\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400, \"Should reject ftp:// notification URLs\"\n    assert b\"is not a valid AppRise URL\" in res.data, \"Should provide AppRise validation error message\"\n\n    # Test 3: Update watch with valid notification URLs\n    new_valid_urls = [\"posts://newserver.com\"]\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        data=json.dumps({\"notification_urls\": new_valid_urls}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 200, \"Should accept valid notification URLs on watch update\"\n\n    # Verify the notification URLs were updated\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json['notification_urls'] == new_valid_urls, \"Valid notification URLs should be updated\"\n\n    # Test 4: Try to update watch with invalid notification URLs (plain https:// not valid)\n    invalid_https_url = [\"https://example.com/webhook\"]\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        data=json.dumps({\"notification_urls\": invalid_https_url}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400, \"Should reject https:// notification URLs on watch update\"\n    assert b\"is not a valid AppRise URL\" in res.data, \"Should provide AppRise validation error message\"\n\n    # Test 5: Update watch with non-list notification_urls (caught by OpenAPI schema validation)\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        data=json.dumps({\"notification_urls\": \"not-a-list\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400, \"Should reject non-list notification_urls\"\n    assert b\"Validation failed\" in res.data or b\"is not of type\" in res.data\n\n    # Test 6: Verify original URLs are preserved after failed update\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json['notification_urls'] == new_valid_urls, \"URLs should remain unchanged after validation failure\"\n\n\ndef test_tag_notification_urls_validation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that Tag PUT endpoint validates notification_urls.\"\"\"\n    from changedetectionio.model import Tag\n\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    datastore = live_server.app.config['DATASTORE']\n\n    # Create a tag\n    tag_uuid = datastore.add_tag(title=\"Test Tag\")\n    assert tag_uuid is not None\n\n    # Test 1: Update tag with valid notification URLs\n    valid_urls = [\"posts://example.com/tag-notify\"]\n    res = client.put(\n        url_for(\"tag\", uuid=tag_uuid),\n        data=json.dumps({\"notification_urls\": valid_urls}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 200, \"Should accept valid notification URLs on tag update\"\n\n    # Verify the notification URLs were saved\n    tag = datastore.data['settings']['application']['tags'][tag_uuid]\n    assert tag['notification_urls'] == valid_urls, \"Valid notification URLs should be saved to tag\"\n\n    # Test 2: Try to update tag with invalid notification URLs (https:// not valid)\n    invalid_urls = [\"https://example.com/webhook\"]\n    res = client.put(\n        url_for(\"tag\", uuid=tag_uuid),\n        data=json.dumps({\"notification_urls\": invalid_urls}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400, \"Should reject https:// notification URLs on tag update\"\n    assert b\"is not a valid AppRise URL\" in res.data, \"Should provide AppRise validation error message\"\n\n    # Test 3: Update tag with non-list notification_urls (caught by OpenAPI schema validation)\n    res = client.put(\n        url_for(\"tag\", uuid=tag_uuid),\n        data=json.dumps({\"notification_urls\": \"not-a-list\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400, \"Should reject non-list notification_urls\"\n    assert b\"Validation failed\" in res.data or b\"is not of type\" in res.data\n\n    # Test 4: Verify original URLs are preserved after failed update\n    tag = datastore.data['settings']['application']['tags'][tag_uuid]\n    assert tag['notification_urls'] == valid_urls, \"URLs should remain unchanged after validation failure\"\n"
  },
  {
    "path": "changedetectionio/tests/test_api_notifications.py",
    "content": "#!/usr/bin/env python3\n\nfrom flask import url_for\nfrom .util import live_server_setup\nimport json\n\ndef test_api_notifications_crud(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Confirm notifications are initially empty\n    res = client.get(\n        url_for(\"notifications\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json == {\"notification_urls\": []}\n\n    # Add notification URLs\n    test_urls = [\"posts://example.com/notify1\", \"posts://example.com/notify2\"]\n    res = client.post(\n        url_for(\"notifications\"),\n        data=json.dumps({\"notification_urls\": test_urls}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 201\n    for url in test_urls:\n        assert url in res.json[\"notification_urls\"]\n\n    # Confirm the notification URLs were added\n    res = client.get(\n        url_for(\"notifications\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    for url in test_urls:\n        assert url in res.json[\"notification_urls\"]\n\n    # Delete one notification URL\n    res = client.delete(\n        url_for(\"notifications\"),\n        data=json.dumps({\"notification_urls\": [test_urls[0]]}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 204\n\n    # Confirm it was removed and the other remains\n    res = client.get(\n        url_for(\"notifications\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert test_urls[0] not in res.json[\"notification_urls\"]\n    assert test_urls[1] in res.json[\"notification_urls\"]\n\n    # Try deleting a non-existent URL\n    res = client.delete(\n        url_for(\"notifications\"),\n        data=json.dumps({\"notification_urls\": [\"posts://nonexistent.com\"]}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400\n\n    res = client.post(\n        url_for(\"notifications\"),\n        data=json.dumps({\"notification_urls\": test_urls}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 201\n\n    # Replace with a new list\n    replacement_urls = [\"posts://new.example.com\"]\n    res = client.put(\n        url_for(\"notifications\"),\n        data=json.dumps({\"notification_urls\": replacement_urls}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json[\"notification_urls\"] == replacement_urls\n\n    # Replace with an empty list\n    res = client.put(\n        url_for(\"notifications\"),\n        data=json.dumps({\"notification_urls\": []}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json[\"notification_urls\"] == []\n\n    # Provide an invalid AppRise URL to trigger validation error\n    invalid_urls = [\"ftp://not-app-rise\"]\n    res = client.post(\n        url_for(\"notifications\"),\n        data=json.dumps({\"notification_urls\": invalid_urls}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400\n    assert \"is not a valid AppRise URL.\" in res.data.decode()\n\n    res = client.put(\n        url_for(\"notifications\"),\n        data=json.dumps({\"notification_urls\": invalid_urls}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 400\n    assert \"is not a valid AppRise URL.\" in res.data.decode()\n\n    "
  },
  {
    "path": "changedetectionio/tests/test_api_openapi.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nOpenAPI validation tests for ChangeDetection.io API\n\nThis test file specifically verifies that OpenAPI validation is working correctly\nby testing various scenarios that should trigger validation errors.\n\"\"\"\n\nimport time\nimport json\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\n\n\ndef test_openapi_merged_spec_contains_restock_fields():\n    \"\"\"\n    Unit test: verify that build_merged_spec_dict() correctly merges the\n    restock_diff processor api.yaml into the base spec so that\n    WatchBase.properties includes processor_config_restock_diff with all\n    expected sub-fields.  No live server required.\n    \"\"\"\n    from changedetectionio.api import build_merged_spec_dict\n\n    spec = build_merged_spec_dict()\n    schemas = spec['components']['schemas']\n\n    # The merged schema for processor_config_restock_diff should exist\n    assert 'processor_config_restock_diff' in schemas, \\\n        \"processor_config_restock_diff schema missing from merged spec\"\n\n    restock_schema = schemas['processor_config_restock_diff']\n    props = restock_schema.get('properties', {})\n\n    expected_fields = {\n        'in_stock_processing',\n        'follow_price_changes',\n        'price_change_min',\n        'price_change_max',\n        'price_change_threshold_percent',\n    }\n    missing = expected_fields - set(props.keys())\n    assert not missing, f\"Missing fields in processor_config_restock_diff schema: {missing}\"\n\n    # in_stock_processing must be an enum with the three valid values\n    enum_values = set(props['in_stock_processing'].get('enum', []))\n    assert enum_values == {'in_stock_only', 'all_changes', 'off'}, \\\n        f\"Unexpected enum values for in_stock_processing: {enum_values}\"\n\n    # WatchBase.properties must carry a $ref to the restock schema so the\n    # validation middleware can enforce it on every POST/PUT to /watch\n    watchbase_props = schemas['WatchBase']['properties']\n    assert 'processor_config_restock_diff' in watchbase_props, \\\n        \"processor_config_restock_diff not wired into WatchBase.properties\"\n    ref = watchbase_props['processor_config_restock_diff'].get('$ref', '')\n    assert 'processor_config_restock_diff' in ref, \\\n        f\"WatchBase.processor_config_restock_diff should $ref the schema, got: {ref}\"\n\n\ndef test_openapi_validation_invalid_content_type_on_create_watch(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that creating a watch with invalid content-type triggers OpenAPI validation error.\"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Try to create a watch with JSON data but without proper content-type header\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"https://example.com\", \"title\": \"Test Watch\"}),\n        headers={'x-api-key': api_key},  # Missing 'content-type': 'application/json'\n        follow_redirects=True\n    )\n\n    # Should get 400 error due to OpenAPI validation failure\n    assert res.status_code == 400, f\"Expected 400 but got {res.status_code}\"\n    assert b\"Validation failed\" in res.data, \"Should contain validation error message\"\n    delete_all_watches(client)\n\n\ndef test_openapi_validation_missing_required_field_create_watch(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that creating a watch without required URL field triggers OpenAPI validation error.\"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Try to create a watch without the required 'url' field\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"title\": \"Test Watch Without URL\"}),  # Missing required 'url' field\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        follow_redirects=True\n    )\n\n    # Should get 400 error due to missing required field\n    assert res.status_code == 400, f\"Expected 400 but got {res.status_code}\"\n    assert b\"Validation failed\" in res.data, \"Should contain validation error message\"\n    delete_all_watches(client)\n\n\ndef test_openapi_validation_invalid_field_in_request_body(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that including invalid fields triggers OpenAPI validation error.\"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # First create a valid watch\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"https://example.com\", \"title\": \"Test Watch\"}),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        follow_redirects=True\n    )\n    assert res.status_code == 201, \"Watch creation should succeed\"\n\n    # Get the watch list to find the UUID\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    watch_uuid = list(res.json.keys())[0]\n\n    # Now try to update the watch with an invalid field\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\n            \"title\": \"Updated title\",\n            \"invalid_field_that_doesnt_exist\": \"this should cause validation error\"\n        }),\n    )\n\n    # Should get 400 error due to invalid field (this will be caught by internal validation)\n    # Note: This tests the flow where OpenAPI validation passes but internal validation catches it\n    assert res.status_code == 400, f\"Expected 400 but got {res.status_code}\"\n    # Backend validation now returns \"Unknown field(s):\" message\n    assert b\"Unknown field\" in res.data, \\\n            \"Should contain validation error about unknown fields\"\n    delete_all_watches(client)\n\n\ndef test_openapi_validation_import_wrong_content_type(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that import endpoint with wrong content-type triggers OpenAPI validation error.\"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Try to import URLs with JSON content-type instead of text/plain\n    res = client.post(\n        url_for(\"import\") + \"?tag=test-import\",\n        data='https://website1.com\\nhttps://website2.com',\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},  # Wrong content-type\n        follow_redirects=True\n    )\n\n    # Should get 400 error due to content-type mismatch\n    assert res.status_code == 400, f\"Expected 400 but got {res.status_code}\"\n    assert b\"Validation failed\" in res.data, \"Should contain validation error message\"\n    delete_all_watches(client)\n\n\ndef test_openapi_validation_import_correct_content_type_succeeds(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that import endpoint with correct content-type succeeds (positive test).\"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Import URLs with correct text/plain content-type\n    res = client.post(\n        url_for(\"import\") + \"?tag=test-import\",\n        data='https://website1.com\\nhttps://website2.com',\n        headers={'x-api-key': api_key, 'content-type': 'text/plain'},  # Correct content-type\n        follow_redirects=True\n    )\n\n    # Should succeed\n    assert res.status_code == 200, f\"Expected 200 but got {res.status_code}\"\n    assert len(res.json) == 2, \"Should import 2 URLs\"\n    delete_all_watches(client)\n\n\ndef test_openapi_validation_get_requests_bypass_validation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that GET requests bypass OpenAPI validation entirely.\"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Disable API token requirement first\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-fetch_backend\": \"html_requests\",\n            \"application-api_access_token_enabled\": \"\"\n        },\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Make GET request to list watches - should succeed even without API key or content-type\n    res = client.get(url_for(\"createwatch\"))  # No headers needed for GET\n    assert res.status_code == 200, f\"GET requests should succeed without OpenAPI validation, got {res.status_code}\"\n\n    # Should return JSON with watch list (empty in this case)\n    assert isinstance(res.json, dict), \"Should return JSON dictionary for watch list\"\n    delete_all_watches(client)\n\n\ndef test_openapi_validation_create_tag_missing_required_title(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that creating a tag without required title triggers OpenAPI validation error.\"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Try to create a tag without the required 'title' field\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\"notification_urls\": [\"mailto:test@example.com\"]}),  # Missing required 'title' field\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        follow_redirects=True\n    )\n\n    # Should get 400 error due to missing required field\n    assert res.status_code == 400, f\"Expected 400 but got {res.status_code}\"\n    assert b\"Validation failed\" in res.data, \"Should contain validation error message\"\n    delete_all_watches(client)\n\n\ndef test_openapi_validation_watch_update_allows_partial_updates(client, live_server, measure_memory_usage, datastore_path):\n\n    \"\"\"Test that watch updates allow partial updates without requiring all fields (positive test).\"\"\"\n#xxx\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # First create a valid watch\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": \"https://example.com\", \"title\": \"Test Watch\"}),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        follow_redirects=True\n    )\n    assert res.status_code == 201, \"Watch creation should succeed\"\n\n    # Get the watch list to find the UUID\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    watch_uuid = list(res.json.keys())[0]\n\n    # Update only the title (partial update) - should succeed\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\"title\": \"Updated Title Only\"}),  # Only updating title, not URL\n    )\n\n    # Should succeed because UpdateWatch schema allows partial updates\n    assert res.status_code == 200, f\"Partial updates should succeed, got {res.status_code}\"\n\n    # Verify the update worked\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json.get('title') == 'Updated Title Only', \"Title should be updated\"\n    assert res.json.get('url') == 'https://example.com', \"URL should remain unchanged\"\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_api_search.py",
    "content": "from copy import copy\n\nfrom flask import url_for\nimport json\nimport time\nfrom .util import live_server_setup, wait_for_all_checks\n\n\ndef test_api_search(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    watch_data = {}\n    # Add some test watches\n    urls = [\n        'https://example.com/page1',\n        'https://example.org/testing',\n        'https://test-site.com/example'\n    ]\n\n    # Import the test URLs\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": \"\\r\\n\".join(urls)},\n        follow_redirects=True\n    )\n    assert b\"3 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # Get a listing, it will be the first one\n    watches_response = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': api_key}\n    )\n\n\n    # Add a title to one watch for title search testing\n    for uuid, watch in watches_response.json.items():\n\n        watch_data = client.get(url_for(\"watch\", uuid=uuid),\n                                follow_redirects=True,\n                                headers={'x-api-key': api_key}\n                                )\n\n        if urls[0] == watch_data.json['url']:\n            # HTTP PUT ( UPDATE an existing watch )\n            client.put(\n                url_for(\"watch\", uuid=uuid),\n                headers={'x-api-key': api_key, 'content-type': 'application/json'},\n                data=json.dumps({'title': 'Example Title Test'}),\n            )\n\n    # Test search by URL\n    res = client.get(url_for(\"search\")+\"?q=https://example.com/page1\", headers={'x-api-key': api_key, 'content-type': 'application/json'})\n    assert len(res.json) == 1\n    assert list(res.json.values())[0]['url'] == urls[0]\n\n    # Test search by URL - partial should NOT match without ?partial=true flag\n    res = client.get(url_for(\"search\")+\"?q=https://example\", headers={'x-api-key': api_key, 'content-type': 'application/json'})\n    assert len(res.json) == 0\n\n\n    # Test search by title\n    res = client.get(url_for(\"search\")+\"?q=Example Title Test\", headers={'x-api-key': api_key, 'content-type': 'application/json'})\n    assert len(res.json) == 1\n    assert list(res.json.values())[0]['url'] == urls[0]\n    assert list(res.json.values())[0]['title'] == 'Example Title Test'\n\n    # Test search that should return multiple results (partial = true)\n    res = client.get(url_for(\"search\")+\"?q=https://example&partial=true\", headers={'x-api-key': api_key, 'content-type': 'application/json'})\n    assert len(res.json) == 2\n\n    # Test empty search\n    res = client.get(url_for(\"search\")+\"?q=\", headers={'x-api-key': api_key, 'content-type': 'application/json'})\n    assert res.status_code == 400\n\n    # Add a tag to test search with tag filter\n    tag_name = 'test-tag'\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\"title\": tag_name}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 201\n    tag_uuid = res.json['uuid']\n\n    # Add the tag to one watch\n    for uuid, watch in watches_response.json.items():\n        if urls[2] == watch['url']:\n            client.put(\n                url_for(\"watch\", uuid=uuid),\n                headers={'x-api-key': api_key, 'content-type': 'application/json'},\n                data=json.dumps({'tags': [tag_uuid]}),\n            )\n\n\n    # Test search with tag filter and q\n    res = client.get(url_for(\"search\") + f\"?q={urls[2]}&tag={tag_name}\", headers={'x-api-key': api_key, 'content-type': 'application/json'})\n    assert len(res.json) == 1\n    assert list(res.json.values())[0]['url'] == urls[2]\n\n"
  },
  {
    "path": "changedetectionio/tests/test_api_security.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nComprehensive security and edge case tests for the API.\nTests critical areas that were identified as gaps in the existing test suite.\n\"\"\"\n\nimport time\nimport json\nimport threading\nimport uuid as uuid_module\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nimport os\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     </body>\n     </html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef is_valid_uuid(val):\n    try:\n        uuid_module.UUID(str(val))\n        return True\n    except ValueError:\n        return False\n\n\n# ============================================================================\n# TIER 1: CRITICAL SECURITY TESTS\n# ============================================================================\n\ndef test_api_path_traversal_in_uuids(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that path traversal attacks via UUID parameter are blocked.\n    Addresses CVE-like vulnerabilities where ../../../ in UUID could access arbitrary files.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create a valid watch first\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url, \"title\": \"Valid watch\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 201\n    valid_uuid = res.json.get('uuid')\n\n    # Test 1: Path traversal with ../../../\n    res = client.get(\n        f\"/api/v1/watch/../../etc/passwd\",\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code in [400, 404], \"Path traversal should be rejected\"\n\n    # Test 2: Encoded path traversal\n    res = client.get(\n        \"/api/v1/watch/..%2F..%2F..%2Fetc%2Fpasswd\",\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code in [400, 404], \"Encoded path traversal should be rejected\"\n\n    # Test 3: Double-encoded path traversal\n    res = client.get(\n        \"/api/v1/watch/%2e%2e%2f%2e%2e%2f%2e%2e%2f\",\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code in [400, 404], \"Double-encoded traversal should be rejected\"\n\n    # Test 4: Try to access datastore file\n    res = client.get(\n        \"/api/v1/watch/../url-watches.json\",\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code in [400, 404], \"Access to datastore should be blocked\"\n\n    # Test 5: Null byte injection\n    res = client.get(\n        f\"/api/v1/watch/{valid_uuid}%00.json\",\n        headers={'x-api-key': api_key}\n    )\n    # Should either work (ignoring null byte) or reject - but not crash\n    assert res.status_code in [200, 400, 404]\n\n    # Test 6: DELETE with path traversal\n    res = client.delete(\n        \"/api/v1/watch/../../datastore/url-watches.json\",\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code in [400, 404, 405], \"DELETE with traversal should be blocked (405=method not allowed is also acceptable)\"\n\n    # Cleanup\n    client.delete(url_for(\"watch\", uuid=valid_uuid), headers={'x-api-key': api_key})\n    delete_all_watches(client)\n\n\ndef test_api_injection_via_headers_and_proxy(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that injection attacks via headers and proxy fields are properly sanitized.\n    Addresses XSS and injection vulnerabilities.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: XSS in headers\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"headers\": {\n                \"User-Agent\": \"<script>alert(1)</script>\",\n                \"X-Custom\": \"'; DROP TABLE watches; --\"\n            }\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Headers are metadata used for HTTP requests, not HTML rendering\n    # Storing them as-is is expected behavior\n    assert res.status_code in [201, 400]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        # Verify headers are stored (API returns JSON, not HTML, so no XSS risk)\n        res = client.get(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n        assert res.status_code == 200\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 2: Null bytes in headers\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"headers\": {\"X-Test\": \"value\\x00null\"}\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should handle null bytes gracefully (reject or sanitize)\n    assert res.status_code in [201, 400]\n\n    # Test 3: Malformed proxy string\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"proxy\": \"http://evil.com:8080@victim.com\"\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should reject invalid proxy format\n    assert res.status_code == 400\n\n    # Test 4: Control characters in notification title\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"notification_title\": \"Test\\r\\nInjected-Header: value\"\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should accept but sanitize control characters\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    delete_all_watches(client)\n\n\ndef test_api_large_payload_dos(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that excessively large payloads are rejected to prevent DoS.\n    Addresses memory leak issues found in changelog.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: Huge ignore_text array\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"ignore_text\": [\"a\" * 10000] * 100  # 1MB of data\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should either accept (with limits) or reject\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 2: Massive headers object\n    huge_headers = {f\"X-Header-{i}\": \"x\" * 1000 for i in range(100)}\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"headers\": huge_headers\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should reject or truncate\n    assert res.status_code in [201, 400, 413]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 3: Huge browser_steps array\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"browser_steps\": [\n                {\"operation\": \"click\", \"selector\": \"#test\" * 1000, \"optional_value\": \"\"}\n            ] * 100\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should reject or limit\n    assert res.status_code in [201, 400, 413]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 4: Extremely long title\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"title\": \"x\" * 100000  # 100KB title\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should reject (exceeds maxLength: 5000)\n    assert res.status_code == 400\n\n    delete_all_watches(client)\n\n\ndef test_api_utf8_encoding_edge_cases(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test UTF-8 encoding edge cases that have caused bugs on Windows.\n    Addresses 18+ encoding bugs from changelog.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: Unicode in title (should work)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"title\": \"Test 中文 Ελληνικά 日本語 🔥\"\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 201\n    watch_uuid = res.json.get('uuid')\n\n    # Verify it round-trips correctly\n    res = client.get(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n    assert res.status_code == 200\n    assert \"中文\" in res.json.get('title')\n\n    client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 2: Unicode in URL query parameters\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url + \"?search=日本語\"\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should handle URL encoding properly\n    assert res.status_code in [201, 400]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 3: Null byte in title (should be rejected or sanitized)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"title\": \"Test\\x00Title\"\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should handle gracefully\n    assert res.status_code in [201, 400]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 4: BOM (Byte Order Mark) in title\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"title\": \"\\ufeffTest with BOM\"\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code in [201, 400]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    delete_all_watches(client)\n\n\ndef test_api_concurrency_race_conditions(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test concurrent API requests to detect race conditions.\n    Addresses 20+ concurrency bugs from changelog.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create a watch\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url, \"title\": \"Concurrency test\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 201\n    watch_uuid = res.json.get('uuid')\n    wait_for_all_checks(client)\n\n    # Test 1: Concurrent updates to same watch\n    # Note: Flask test client is not thread-safe, so we test sequential updates instead\n    # Real concurrency issues would be caught in integration tests with actual HTTP requests\n    results = []\n    for i in range(10):\n        try:\n            r = client.put(\n                url_for(\"watch\", uuid=watch_uuid),\n                data=json.dumps({\"title\": f\"Title {i}\"}),\n                headers={'content-type': 'application/json', 'x-api-key': api_key},\n            )\n            results.append(r.status_code)\n        except Exception as e:\n            results.append(str(e))\n\n    # All updates should succeed (200) without crashes\n    assert all(r == 200 for r in results), f\"Some updates failed: {results}\"\n\n    # Test 2: Update while watch is being checked\n    # Queue a recheck\n    client.get(\n        url_for(\"watch\", uuid=watch_uuid, recheck=True),\n        headers={'x-api-key': api_key}\n    )\n\n    # Immediately update it\n    res = client.put(\n        url_for(\"watch\", uuid=watch_uuid),\n        data=json.dumps({\"title\": \"Updated during check\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should succeed without error\n    assert res.status_code == 200\n\n    # Test 3: Delete watch that's being processed\n    # Create another watch\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    watch_uuid2 = res.json.get('uuid')\n\n    # Queue it for checking\n    client.get(url_for(\"watch\", uuid=watch_uuid2, recheck=True), headers={'x-api-key': api_key})\n\n    # Immediately delete it\n    res = client.delete(url_for(\"watch\", uuid=watch_uuid2), headers={'x-api-key': api_key})\n    # Should succeed or return appropriate error\n    assert res.status_code in [204, 404, 400]\n\n    # Cleanup\n    client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n    delete_all_watches(client)\n\n\n# ============================================================================\n# TIER 2: IMPORTANT FUNCTIONALITY TESTS\n# ============================================================================\n\ndef test_api_time_validation_edge_cases(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test time_between_check validation edge cases.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: Zero interval\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\"seconds\": 0}\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 400, \"Zero interval should be rejected\"\n\n    # Test 2: Negative interval\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\"seconds\": -100}\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 400, \"Negative interval should be rejected\"\n\n    # Test 3: All fields null with use_default=false\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\"weeks\": None, \"days\": None, \"hours\": None, \"minutes\": None, \"seconds\": None}\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 400, \"All null intervals should be rejected when not using default\"\n\n    # Test 4: Extremely large interval (overflow risk)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\"weeks\": 999999999}\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should either accept (with limits) or reject\n    assert res.status_code in [201, 400]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 5: Valid minimal interval (should work)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"time_between_check_use_default\": False,\n            \"time_between_check\": {\"seconds\": 60}\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    assert res.status_code == 201\n    watch_uuid = res.json.get('uuid')\n    client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    delete_all_watches(client)\n\n\ndef test_api_browser_steps_validation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test browser_steps validation for invalid operations and structures.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: Empty browser step\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"browser_steps\": [\n                {\"operation\": \"\", \"selector\": \"\", \"optional_value\": \"\"}\n            ]\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should accept (empty is valid as null)\n    assert res.status_code in [201, 400]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 2: Invalid operation type\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"browser_steps\": [\n                {\"operation\": \"invalid_operation\", \"selector\": \"#test\", \"optional_value\": \"\"}\n            ]\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should accept (validation happens at runtime) or reject\n    assert res.status_code in [201, 400]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 3: Missing required fields in browser step\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"browser_steps\": [\n                {\"operation\": \"click\"}  # Missing selector and optional_value\n            ]\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should be rejected due to schema validation\n    assert res.status_code == 400\n\n    # Test 4: Extra fields in browser step\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"browser_steps\": [\n                {\"operation\": \"click\", \"selector\": \"#test\", \"optional_value\": \"\", \"extra_field\": \"value\"}\n            ]\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should be rejected due to additionalProperties: false\n    assert res.status_code == 400\n\n    delete_all_watches(client)\n\n\ndef test_api_queue_manipulation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test queue behavior under stress and edge cases.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: Create many watches rapidly\n    watch_uuids = []\n    for i in range(20):\n        res = client.post(\n            url_for(\"createwatch\"),\n            data=json.dumps({\"url\": test_url, \"title\": f\"Watch {i}\"}),\n            headers={'content-type': 'application/json', 'x-api-key': api_key},\n        )\n        if res.status_code == 201:\n            watch_uuids.append(res.json.get('uuid'))\n\n    assert len(watch_uuids) == 20, \"Should be able to create 20 watches\"\n\n    # Test 2: Recheck all when watches exist\n    res = client.get(\n        url_for(\"createwatch\", recheck_all='1'),\n        headers={'x-api-key': api_key},\n    )\n    # Should return success (200 or 202 for background processing)\n    assert res.status_code in [200, 202]\n\n    # Test 3: Verify queue doesn't overflow with moderate load\n    # The app has MAX_QUEUE_SIZE = 5000, we're well below that\n    wait_for_all_checks(client)\n\n    # Cleanup\n    for uuid in watch_uuids:\n        client.delete(url_for(\"watch\", uuid=uuid), headers={'x-api-key': api_key})\n\n    delete_all_watches(client)\n\n\n# ============================================================================\n# TIER 3: EDGE CASES & POLISH\n# ============================================================================\n\ndef test_api_history_edge_cases(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test history API with invalid timestamps and edge cases.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create watch and generate history\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    watch_uuid = res.json.get('uuid')\n    wait_for_all_checks(client)\n\n    # Test 1: Get history with invalid timestamp\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=watch_uuid, timestamp=\"invalid\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 404, \"Invalid timestamp should return 404\"\n\n    # Test 2: Future timestamp\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=watch_uuid, timestamp=\"9999999999\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 404, \"Future timestamp should return 404\"\n\n    # Test 3: Negative timestamp\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=watch_uuid, timestamp=\"-1\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 404, \"Negative timestamp should return 404\"\n\n    # Test 4: Diff with reversed timestamps (from > to)\n    # First get actual timestamps\n    res = client.get(\n        url_for(\"watchhistory\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    if len(res.json) >= 2:\n        timestamps = sorted(res.json.keys())\n        # Try reversed order\n        res = client.get(\n            url_for(\"watchhistorydiff\", uuid=watch_uuid, from_timestamp=timestamps[-1], to_timestamp=timestamps[0]),\n            headers={'x-api-key': api_key}\n        )\n        # Should either work (show reverse diff) or return error\n        assert res.status_code in [200, 400]\n\n    # Cleanup\n    client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n    delete_all_watches(client)\n\n\ndef test_api_notification_edge_cases(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test notification configuration edge cases.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: Invalid notification URL\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"notification_urls\": [\"invalid://url\", \"ftp://test.com\"]\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should accept (apprise validates at runtime) or reject\n    assert res.status_code in [201, 400]\n    if res.status_code == 201:\n        watch_uuid = res.json.get('uuid')\n        client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    # Test 2: Invalid notification format\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"notification_format\": \"invalid_format\"\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should be rejected by schema\n    assert res.status_code == 400\n\n    # Test 3: Empty notification arrays\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            \"url\": test_url,\n            \"notification_urls\": []\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should accept (empty is valid)\n    assert res.status_code == 201\n    watch_uuid = res.json.get('uuid')\n    client.delete(url_for(\"watch\", uuid=watch_uuid), headers={'x-api-key': api_key})\n\n    delete_all_watches(client)\n\n\ndef test_api_tag_edge_cases(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test tag/group API edge cases including XSS and path traversal.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Test 1: Empty tag title\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\"title\": \"\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should be rejected (empty title)\n    assert res.status_code == 400\n\n    # Test 2: XSS in tag title\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\"title\": \"<script>alert(1)</script>\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should accept but sanitize\n    if res.status_code == 201:\n        tag_uuid = res.json.get('uuid')\n        # Verify title is stored safely\n        res = client.get(url_for(\"tag\", uuid=tag_uuid), headers={'x-api-key': api_key})\n        # Should be escaped or sanitized\n        client.delete(url_for(\"tag\", uuid=tag_uuid), headers={'x-api-key': api_key})\n\n    # Test 3: Path traversal in tag title\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\"title\": \"../../etc/passwd\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should accept (it's just a string, not a path)\n    if res.status_code == 201:\n        tag_uuid = res.json.get('uuid')\n        client.delete(url_for(\"tag\", uuid=tag_uuid), headers={'x-api-key': api_key})\n\n    # Test 4: Very long tag title\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\"title\": \"x\" * 10000}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n    )\n    # Should be rejected (exceeds maxLength)\n    assert res.status_code == 400\n\n\ndef test_api_authentication_edge_cases(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test API authentication edge cases.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Test 1: Missing API key\n    res = client.get(url_for(\"createwatch\"))\n    assert res.status_code == 403, \"Missing API key should be forbidden\"\n\n    # Test 2: Invalid API key\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': \"invalid_key_12345\"}\n    )\n    assert res.status_code == 403, \"Invalid API key should be forbidden\"\n\n    # Test 3: API key with special characters\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': \"key<script>alert(1)</script>\"}\n    )\n    assert res.status_code == 403, \"Invalid API key should be forbidden\"\n\n    # Test 4: Very long API key\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': \"x\" * 10000}\n    )\n    assert res.status_code == 403, \"Invalid API key should be forbidden\"\n\n    # Test 5: Case sensitivity of API key\n    wrong_case_key = api_key.upper() if api_key.islower() else api_key.lower()\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': wrong_case_key}\n    )\n    # Should be forbidden (keys are case-sensitive)\n    assert res.status_code == 403, \"Wrong case API key should be forbidden\"\n\n    # Test 6: Valid API key should work\n    res = client.get(\n        url_for(\"createwatch\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200, \"Valid API key should work\"\n"
  },
  {
    "path": "changedetectionio/tests/test_api_tags.py",
    "content": "#!/usr/bin/env python3\n\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, set_original_response\nimport json\nimport time\n\ndef test_api_tags_listing(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    tag_title = 'Test Tag'\n\n\n    set_original_response(datastore_path=datastore_path)\n\n\n    res = client.get(\n        url_for(\"tags\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.get_data(as_text=True).strip() == \"{}\", \"Should be empty list\"\n    assert res.status_code == 200\n\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\"title\": tag_title}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 201\n\n    new_tag_uuid = res.json.get('uuid')\n\n    # List tags - should include our new tag\n    res = client.get(\n        url_for(\"tags\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert new_tag_uuid in res.get_data(as_text=True)\n    assert res.json[new_tag_uuid]['title'] == tag_title\n    assert res.json[new_tag_uuid]['notification_muted'] == False\n\n    # Get single tag\n    res = client.get(\n        url_for(\"tag\", uuid=new_tag_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json['title'] == tag_title\n\n    # Update tag\n    res = client.put(\n        url_for(\"tag\", uuid=new_tag_uuid),\n        data=json.dumps({\"title\": \"Updated Tag\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert b'OK' in res.data\n\n    # Verify update worked\n    res = client.get(\n        url_for(\"tag\", uuid=new_tag_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json['title'] == 'Updated Tag'\n\n    # Mute tag notifications\n    res = client.get(\n        url_for(\"tag\", uuid=new_tag_uuid) + \"?muted=muted\",\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert b'OK' in res.data\n\n    # Verify muted status\n    res = client.get(\n        url_for(\"tag\", uuid=new_tag_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json['notification_muted'] == True\n\n    # Unmute tag\n    res = client.get(\n        url_for(\"tag\", uuid=new_tag_uuid) + \"?muted=unmuted\",\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert b'OK' in res.data\n\n    # Verify unmuted status\n    res = client.get(\n        url_for(\"tag\", uuid=new_tag_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert res.json['notification_muted'] == False\n\n    # Create a watch with the tag and check it matches UUID\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\"url\": test_url, \"tag\": \"Updated Tag\", \"title\": \"Watch with tag\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key},\n        follow_redirects=True\n    )\n    assert res.status_code == 201\n    watch_uuid = res.json.get('uuid')\n\n\n    wait_for_all_checks()\n    # Verify tag is associated with watch by name if need be\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert new_tag_uuid in res.json.get('tags', [])\n\n    # Test that tags are returned when listing ALL watches (issue #3854)\n    res = client.get(\n        url_for(\"createwatch\"),  # GET /api/v1/watch - list all watches\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert watch_uuid in res.json, \"Watch should be in the list\"\n    assert 'tags' in res.json[watch_uuid], \"Tags field should be present in watch list\"\n    assert new_tag_uuid in res.json[watch_uuid]['tags'], \"Tag UUID should be in tags array\"\n\n    # Check recheck by tag\n    before_check_time = live_server.app.config['DATASTORE'].data['watching'][watch_uuid].get('last_checked')\n    time.sleep(1)\n    res = client.get(\n       url_for(\"tag\", uuid=new_tag_uuid) + \"?recheck=true\",\n       headers={'x-api-key': api_key}\n    )\n\n    assert res.status_code == 200\n    assert b'OK, queued 1 watches for rechecking' in res.data\n\n\n    wait_for_all_checks()\n    after_check_time = live_server.app.config['DATASTORE'].data['watching'][watch_uuid].get('last_checked')\n\n    assert before_check_time != after_check_time\n\n    # Delete tag\n    res = client.delete(\n        url_for(\"tag\", uuid=new_tag_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 204\n\n    # Verify tag is gone\n    res = client.get(\n        url_for(\"tags\"),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert new_tag_uuid not in res.get_data(as_text=True)\n\n    # Verify tag was removed from watch\n    res = client.get(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    assert new_tag_uuid not in res.json.get('tags', [])\n\n    # Delete the watch\n    res = client.delete(\n        url_for(\"watch\", uuid=watch_uuid),\n        headers={'x-api-key': api_key},\n    )\n    assert res.status_code == 204\n\n\ndef test_api_tag_restock_processor_config(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that a tag/group can be created and updated with processor_config_restock_diff via the API.\n    Since Tag extends WatchBase, processor config fields injected into WatchBase are also valid for tags.\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    set_original_response(datastore_path=datastore_path)\n\n    # Create a tag with processor_config_restock_diff in a single POST (issue #3966)\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\n            \"title\": \"Restock Group\",\n            \"overrides_watch\": True,\n            \"processor_config_restock_diff\": {\n                \"in_stock_processing\": \"in_stock_only\",\n                \"follow_price_changes\": True,\n                \"price_change_min\": 7777777\n            }\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 201, f\"POST tag with restock config failed: {res.data}\"\n    tag_uuid = res.json.get('uuid')\n\n    # Verify processor config was saved during creation (the bug: these were discarded)\n    res = client.get(\n        url_for(\"tag\", uuid=tag_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    tag_data = res.json\n    assert tag_data.get('overrides_watch') == True, \"overrides_watch should be saved on POST\"\n    assert tag_data.get('processor_config_restock_diff', {}).get('in_stock_processing') == 'in_stock_only', \\\n        \"processor_config_restock_diff should be saved on POST\"\n    assert tag_data.get('processor_config_restock_diff', {}).get('price_change_min') == 7777777, \\\n        \"price_change_min should be saved on POST\"\n\n    # Update tag with valid processor_config_restock_diff via PUT\n    res = client.put(\n        url_for(\"tag\", uuid=tag_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\n            \"overrides_watch\": True,\n            \"processor_config_restock_diff\": {\n                \"in_stock_processing\": \"in_stock_only\",\n                \"follow_price_changes\": True,\n                \"price_change_min\": 8888888\n            }\n        })\n    )\n    assert res.status_code == 200, f\"PUT tag with restock config failed: {res.data}\"\n\n    # Verify the config was stored via API\n    res = client.get(\n        url_for(\"tag\", uuid=tag_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 200\n    tag_data = res.json\n    assert tag_data.get('overrides_watch') == True\n    assert tag_data.get('processor_config_restock_diff', {}).get('in_stock_processing') == 'in_stock_only'\n    assert tag_data.get('processor_config_restock_diff', {}).get('price_change_min') == 8888888\n\n    # Verify the value is also reflected in the UI tag edit page\n    res = client.get(url_for(\"tags.form_tag_edit\", uuid=tag_uuid))\n    assert res.status_code == 200\n    assert b'8888888' in res.data, \"price_change_min set via API should appear in the UI tag edit form\"\n\n    # Invalid enum value should be rejected by OpenAPI spec validation\n    res = client.put(\n        url_for(\"tag\", uuid=tag_uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps({\n            \"processor_config_restock_diff\": {\n                \"in_stock_processing\": \"not_a_valid_value\"\n            }\n        })\n    )\n    assert res.status_code == 400\n    assert b'Validation failed' in res.data\n\n    # Clean up\n    res = client.delete(\n        url_for(\"tag\", uuid=tag_uuid),\n        headers={'x-api-key': api_key}\n    )\n    assert res.status_code == 204\n\n\ndef test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test the full round trip, this way we test the default Model fits back into OpenAPI spec\n    :param client:\n    :param live_server:\n    :param measure_memory_usage:\n    :param datastore_path:\n    :return:\n    \"\"\"\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    set_original_response(datastore_path=datastore_path)\n\n    res = client.post(\n        url_for(\"tag\"),\n        data=json.dumps({\"title\": \"My tag title\"}),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n    assert res.status_code == 201\n\n    uuid = res.json.get('uuid')\n\n    # Now fetch it and send it back\n\n    res = client.get(\n        url_for(\"tag\", uuid=uuid),\n        headers={'x-api-key': api_key}\n    )\n\n    tag = res.json\n\n    # Only test with date_created (readOnly field that should be filtered out)\n    # last_changed is Watch-specific and doesn't apply to Tags\n    tag['date_created'] = 454444444444\n\n    # HTTP PUT ( UPDATE an existing watch )\n    res = client.put(\n        url_for(\"tag\", uuid=uuid),\n        headers={'x-api-key': api_key, 'content-type': 'application/json'},\n        data=json.dumps(tag),\n    )\n    if res.status_code != 200:\n        print(f\"\\n=== PUT failed with {res.status_code} ===\")\n        print(f\"Error: {res.data}\")\n    assert res.status_code == 200, \"HTTP PUT update was sent OK\"\n\n    # Verify readOnly fields like date_created cannot be overridden\n    res = client.get(\n        url_for(\"tag\", uuid=uuid),\n        headers={'x-api-key': api_key}\n    )\n    date_created = res.json.get('date_created')\n    assert date_created != 454444444444, \"ReadOnly date_created should not be updateable\"\n    assert date_created != \"454444444444\", \"ReadOnly date_created should not be updateable\"\n"
  },
  {
    "path": "changedetectionio/tests/test_auth.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\n\n# test pages with http://username@password:foobar.com/ work\ndef test_basic_auth(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n\n    # This page will echo back any auth info\n    test_url = url_for('test_basicauth_method', _external=True).replace(\"//\",\"//myuser:mypass@\")\n    time.sleep(1)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    time.sleep(1)\n    # Check form validation\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": \"\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    wait_for_all_checks(client)\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b'myuser mypass basic' in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_automatic_follow_ldjson_price.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, extract_UUID_from_client, wait_for_all_checks, delete_all_watches\nimport os\n\n\ndef set_response_with_ldjson(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div class=\"sametext\">Some text thats the same</div>\n     <div class=\"changetext\">Some text that will change</div>\n     <script type=\"application/ld+json\">\n        {\n           \"@context\":\"https://schema.org/\",\n           \"@type\":\"Product\",\n           \"@id\":\"https://www.some-virtual-phone-shop.com/celular-iphone-14/p\",\n           \"name\":\"Celular Iphone 14 Pro Max 256Gb E Sim A16 Bionic\",\n           \"brand\":{\n              \"@type\":\"Brand\",\n              \"name\":\"APPLE\"\n           },\n           \"image\":\"https://www.some-virtual-phone-shop.com/15509426/image.jpg\",\n           \"description\":\"You dont need it\",\n           \"mpn\":\"111111\",\n           \"sku\":\"22222\",\n           \"Offers\":{\n              \"@type\":\"AggregateOffer\",\n              \"lowPrice\":8097000,\n              \"highPrice\":8099900,\n              \"priceCurrency\":\"COP\",\n              \"offers\":[\n                 {\n                    \"@type\":\"Offer\",\n                    \"price\":8097000,\n                    \"priceCurrency\":\"COP\",\n                    \"availability\":\"http://schema.org/InStock\",\n                    \"sku\":\"102375961\",\n                    \"itemCondition\":\"http://schema.org/NewCondition\",\n                    \"seller\":{\n                       \"@type\":\"Organization\",\n                       \"name\":\"ajax\"\n                    }\n                 }\n              ],\n              \"offerCount\":1\n           }\n        }\n       </script>\n     </body>\n     </html>\n\"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\ndef set_response_without_ldjson(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div class=\"sametext\">Some text thats the same</div>\n     <div class=\"changetext\">Some text that will change</div>     \n     </body>\n     </html>\n\"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n# actually only really used by the distll.io importer, but could be handy too\ndef test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage, datastore_path):\n    \n    set_response_with_ldjson(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Should get a notice that it's available\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'ldjson-price-track-offer' in res.data\n\n    # Accept it\n    client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True))\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    # Offer should be gone\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'Embedded price data' not in res.data\n    assert b'processor-badge-restock_diff' in res.data\n\n    # and last snapshop (via API) should be just the price\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=uuid, timestamp='latest'),\n        headers={'x-api-key': api_key},\n    )\n\n    assert b'8097000' in res.data\n\n    # And not this cause its not the ld-json\n    assert b\"So let's see what happens\" not in res.data\n\n    delete_all_watches(client)\n\n    ##########################################################################################\n    # And we shouldnt see the offer\n    set_response_without_ldjson(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'ldjson-price-track-offer' not in res.data\n    \n    ##########################################################################################\n    delete_all_watches(client)\n\n\ndef _test_runner_check_bad_format_ignored(live_server, client, has_ldjson_price_data):\n\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    for k,v in client.application.config.get('DATASTORE').data['watching'].items():\n        assert v.get('last_error') == False\n        assert v.get('has_ldjson_price_data') == has_ldjson_price_data, f\"Detected LDJSON data? should be {has_ldjson_price_data}\"\n\n\n    ##########################################################################################\n    delete_all_watches(client)\n\n\ndef test_bad_ldjson_is_correctly_ignored(client, live_server, measure_memory_usage, datastore_path):\n    \n    test_return_data = \"\"\"\n            <html>\n            <head>\n                <script type=\"application/ld+json\">\n                    {\n                        \"@context\": \"http://schema.org\",\n                        \"@type\": [\"Product\", \"SubType\"],\n                        \"name\": \"My test product\",\n                        \"description\": \"\",\n                        \"offers\": {\n                            \"note\" : \"You can see the case-insensitive OffERS key, it should work\",\n                            \"@type\": \"Offer\",\n                            \"offeredBy\": {\n                                \"@type\": \"Organization\",\n                                \"name\":\"Person\",\n                                \"telephone\":\"+1 999 999 999\"\n                            },\n                            \"price\": \"1\",\n                            \"priceCurrency\": \"EUR\",\n                            \"url\": \"/some/url\"\n                        }\n                    }\n                </script>\n            </head>\n            <body>\n            <div class=\"yes\">Some extra stuff</div>\n            </body></html>\n     \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    _test_runner_check_bad_format_ignored(live_server=live_server, client=client, has_ldjson_price_data=True)\n\n    # This is OK that it offers a suggestion in this case, the processor will let them know more about something wrong\n\n    # test_return_data = \"\"\"\n    #         <html>\n    #         <head>\n    #             <script type=\"application/ld+json\">\n    #                 {\n    #                     \"@context\": \"http://schema.org\",\n    #                     \"@type\": [\"Product\", \"SubType\"],\n    #                     \"name\": \"My test product\",\n    #                     \"description\": \"\",\n    #                     \"BrokenOffers\": {\n    #                         \"@type\": \"Offer\",\n    #                         \"offeredBy\": {\n    #                             \"@type\": \"Organization\",\n    #                             \"name\":\"Person\",\n    #                             \"telephone\":\"+1 999 999 999\"\n    #                         },\n    #                         \"price\": \"1\",\n    #                         \"priceCurrency\": \"EUR\",\n    #                         \"url\": \"/some/url\"\n    #                     }\n    #                 }\n    #             </script>\n    #         </head>\n    #         <body>\n    #         <div class=\"yes\">Some extra stuff</div>\n    #         </body></html>\n    #  \"\"\"\n    # with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n    #     f.write(test_return_data)\n    #\n    # _test_runner_check_bad_format_ignored(live_server=live_server, client=client, has_ldjson_price_data=False)\n"
  },
  {
    "path": "changedetectionio/tests/test_backend.py",
    "content": "#!/usr/bin/env python3\nimport os\n\nimport time\nfrom flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \\\n    extract_UUID_from_client, delete_all_watches\n\n\n# Basic test to check inscriptus is not adding return line chars, basically works etc\ndef test_inscriptus():\n    from inscriptis import get_text\n    html_content = \"<html><body>test!<br>ok man</body></html>\"\n    stripped_text_from_html = get_text(html_content)\n    assert stripped_text_from_html == 'test!\\nok man'\n\n\n\ndef test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_endpoint', _external=True))\n\n    # Do this a few times.. ensures we dont accidently set the status\n    for n in range(3):\n        client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n        # Give the thread time to pick it up\n        wait_for_all_checks(client)\n\n        # It should report nothing found (no new 'has-unread-changes' class)\n        res = client.get(url_for(\"watchlist.index\"))\n        assert b'has-unread-changes' not in res.data\n        assert b'test-endpoint' in res.data\n\n        # Default no password set, this stuff should be always available.\n\n        assert b\"SETTINGS\" in res.data\n        assert b\"IMPORT\" in res.data\n\n    #####################\n\n    # Check HTML conversion detected and workd\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    # Check this class does not appear (that we didnt see the actual source)\n    assert b'foobar-detection' not in res.data\n\n    # Check POST preview\n    res = client.post(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    # Check this class does not appear (that we didnt see the actual source)\n    assert b'foobar-detection' not in res.data\n\n\n    # Make a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Force recheck\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    # Check the 'get latest snapshot works'\n    res = client.get(url_for(\"ui.ui_edit.watch_get_latest_html\", uuid=uuid))\n    assert b'which has this one new line' in res.data\n\n    # Now something should be ready, indicated by having a 'has-unread-changes' class\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    # #75, and it should be in the RSS feed\n    rss_token = extract_rss_token_from_UI(client)\n    res = client.get(url_for(\"rss.feed\", token=rss_token, _external=True))\n    expected_url = url_for('test_endpoint', _external=True)\n    assert b'<rss' in res.data\n\n    # re #16 should have the diff in here too\n    assert b'which has this one new line' in res.data\n    assert b'CDATA' in res.data\n\n#\n    # Following the 'diff' link, it should no longer display as 'has-unread-changes' even after we recheck it a few times\n    res = client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=uuid))\n    assert b'selected=\"\"' in res.data, \"Confirm diff history page loaded\"\n\n    assert b'Which is across multiple lines' in res.data\n    # The linefeed should have been added ( @BR@ was replaced with a linefeed because this is htmlcolor kinda display )\n    assert b'Which is across multiple lines</span>\\n' in res.data\n\n    # Check the [preview] pulls the right one\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b'which has this one new line' in res.data\n    assert b'Which is across multiple lines' not in res.data\n\n    wait_for_all_checks(client)\n\n\n    # Do this a few times.. ensures we don't accidently set the status\n    for n in range(2):\n        res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        # Give the thread time to pick it up\n        wait_for_all_checks(client)\n\n        # It should report nothing found (no new 'has-unread-changes' class)\n        res = client.get(url_for(\"watchlist.index\"))\n\n        assert b'has-unread-changes' not in res.data\n        assert b'class=\"has-unread-changes' not in res.data\n        assert b'head title' in res.data  # Should be ON by default\n        assert b'test-endpoint' in res.data\n\n    # Recheck it but only with a title change, content wasnt changed\n    set_original_response(datastore_path=datastore_path, extra_title=\" and more\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'head title and more' in res.data\n\n    # Be sure the last_viewed is going to be greater than the last snapshot\n    time.sleep(1)\n\n    # hit the mark all viewed link\n    res = client.get(url_for(\"ui.mark_all_viewed\"), follow_redirects=True)\n\n    assert b'class=\"has-unread-changes' not in res.data\n    assert b'has-unread-changes' not in res.data\n\n    # #2458 \"clear history\" should make the Watch object update its status correctly when the first snapshot lands again\n    client.get(url_for(\"ui.clear_watch_history\", uuid=uuid))\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'preview/' in res.data\n\n    #\n    # Cleanup everything\n    delete_all_watches(client)\n\ndef test_title_scraper(client, live_server, measure_memory_usage, datastore_path):\n\n    set_original_response(datastore_path=datastore_path)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_endpoint', _external=True))\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks()\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b'head title' in res.data  # Should be ON by default\n\n    # Recheck it but only with a title change, content wasnt changed\n    set_original_response(datastore_path=datastore_path, extra_title=\" and more\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'head title and more' in res.data\n\n    # disable <title> pickup\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-ui-use_page_title_in_list\": \"\",\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    set_original_response(datastore_path=datastore_path, extra_title=\" SHOULD NOT APPEAR\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'SHOULD NOT APPEAR' not in res.data\n\n    delete_all_watches(client)\n\ndef test_title_scraper_html_only(client, live_server, measure_memory_usage, datastore_path):\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write('\"My text document\\nWhere I talk about <title>\\nwhich should not get registered\\n</title>')\n\n    test_url = url_for('test_endpoint', content_type=\"text/plain\", _external=True)\n\n    uuid = client.application.config.get('DATASTORE').add_watch(test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks()\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b'which should not get registered' not in res.data  # Should be ON by default\n    assert not live_server.app.config['DATASTORE'].data['watching'][uuid].get('title')\n\n\n\n\n# Server says its plaintext, we should always treat it as plaintext, and then if they have a filter, try to apply that\ndef test_requests_timeout(client, live_server, measure_memory_usage, datastore_path):\n    delay = 2\n    test_url = url_for('test_endpoint', delay=delay, _external=True)\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-ui-use_page_title_in_list\": \"\",\n              \"requests-time_between_check-minutes\": 180,\n              \"requests-timeout\": delay - 1,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # requests takes >2 sec but we timeout at 1 second\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'Read timed out. (read timeout=1)' in res.data\n\n    ##### Now set a longer timeout\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-ui-use_page_title_in_list\": \"\",\n              \"requests-time_between_check-minutes\": 180,\n              \"requests-timeout\": delay + 1, # timeout should be a second more than the reply time\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'Read timed out' not in res.data\n\ndef test_non_text_mime_or_downloads(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n\n    https://github.com/dgtlmoon/changedetection.io/issues/3434\n    I noticed that a watched website can be monitored fine as long as the server sends content-type: text/plain; charset=utf-8,\n    but once the server sends content-type: application/octet-stream (which is usually done to force the browser to show the Download dialog),\n    changedetection somehow ignores all line breaks and treats the document file as if everything is on one line.\n\n    WHAT THIS DOES - makes the system rely on 'magic' to determine what is it\n\n    :param client:\n    :param live_server:\n    :param measure_memory_usage:\n    :return:\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"some random text that should be split by line\nand not parsed with html_to_text\nthis way we know that it correctly parsed as plain text\n\\r\\n\nok\\r\\n\ngot it\\r\\n\n\"\"\")\n\n    test_url = url_for('test_endpoint', content_type=\"application/octet-stream\", _external=True)\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    ### check the front end\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b\"some random text that should be split by line\\n\" in res.data\n    ####\n\n    # Check the snapshot by API that it has linefeeds too\n    watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    res = client.get(\n        url_for(\"watchhistory\", uuid=watch_uuid),\n        headers={'x-api-key': api_key},\n    )\n\n    # Fetch a snapshot by timestamp, check the right one was found\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=watch_uuid, timestamp=list(res.json.keys())[-1]),\n        headers={'x-api-key': api_key},\n    )\n    assert b\"some random text that should be split by line\\n\" in res.data\n\n\n    delete_all_watches(client)\n\n\ndef test_standard_text_plain(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n\n    https://github.com/dgtlmoon/changedetection.io/issues/3434\n    I noticed that a watched website can be monitored fine as long as the server sends content-type: text/plain; charset=utf-8,\n    but once the server sends content-type: application/octet-stream (which is usually done to force the browser to show the Download dialog),\n    changedetection somehow ignores all line breaks and treats the document file as if everything is on one line.\n\n    The real bug here can be that it will try to process plain-text as HTML, losing <etc>\n\n    :param client:\n    :param live_server:\n    :param measure_memory_usage:\n    :return:\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"some random text that should be split by line\nand not parsed with html_to_text\n<title>Even this title should stay because we are just plain text</title>\nthis way we know that it correctly parsed as plain text\n\\r\\n\nok\\r\\n\ngot it\\r\\n\n\"\"\")\n\n    test_url = url_for('test_endpoint', content_type=\"text/plain\", _external=True)\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    ### check the front end\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"some random text that should be split by line\\n\" in res.data\n    ####\n\n    # Check the snapshot by API that it has linefeeds too\n    watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n    res = client.get(\n        url_for(\"watchhistory\", uuid=watch_uuid),\n        headers={'x-api-key': api_key},\n    )\n\n    # Fetch a snapshot by timestamp, check the right one was found\n    res = client.get(\n        url_for(\"watchsinglehistory\", uuid=watch_uuid, timestamp=list(res.json.keys())[-1]),\n        headers={'x-api-key': api_key},\n    )\n    assert b\"some random text that should be split by line\\n\" in res.data\n    assert b\"<title>Even this title should stay because we are just plain text</title>\" in res.data\n\n    delete_all_watches(client)\n\n# Server says its plaintext, we should always treat it as plaintext\ndef test_plaintext_even_if_xml_content(client, live_server, measure_memory_usage, datastore_path):\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\">\n    <!--Activity and fragment titles-->\n    <string name=\"feed_update_receiver_name\">Abonnementen bijwerken</string>\n</resources>\n\"\"\")\n\n    test_url = url_for('test_endpoint', content_type=\"text/plain\", _external=True)\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b'&lt;string name=&#34;feed_update_receiver_name&#34;' in res.data\n\n    delete_all_watches(client)\n\n# Server says its plaintext, we should always treat it as plaintext, and then if they have a filter, try to apply that\ndef test_plaintext_even_if_xml_content_and_can_apply_filters(client, live_server, measure_memory_usage, datastore_path):\n\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources xmlns:tools=\"http://schemas.android.com/tools\">\n    <!--Activity and fragment titles-->\n    <string name=\"feed_update_receiver_name\">Abonnementen bijwerken</string>\n    <foobar>ok man</foobar>\n</resources>\n\"\"\")\n\n    test_url=url_for('test_endpoint', content_type=\"text/plain\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={\"include_filters\": ['//string']})\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # Check that the string element with the correct name attribute is present\n    # Note: namespace declarations may be included when extracting elements, which is correct XML behavior\n    assert b'feed_update_receiver_name' in res.data\n    assert b'Abonnementen bijwerken' in res.data\n    assert b'&lt;foobar' not in res.data\n\n    res = delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_backup.py",
    "content": "#!/usr/bin/env python3\n\nfrom .util import set_original_response, live_server_setup, wait_for_all_checks\nfrom flask import url_for\nimport io\nfrom zipfile import ZipFile, ZIP_DEFLATED\nimport re\nimport time\nfrom changedetectionio.model import Watch, Tag\n\n\ndef test_backup(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)+\"?somechar=őőőőőőőő\"},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # Launch the thread in the background to create the backup\n    res = client.get(\n        url_for(\"backups.request_backup\"),\n        follow_redirects=True\n    )\n    time.sleep(4)\n\n    res = client.get(\n        url_for(\"backups.create\"),\n        follow_redirects=True\n    )\n    # Can see the download link to the backup\n    assert b'<a href=\"/backups/download/changedetection-backup-20' in res.data\n    assert b'Remove backups' in res.data\n\n    # Get the latest one\n    res = client.get(\n        url_for(\"backups.download_backup\", filename=\"latest\"),\n        follow_redirects=True\n    )\n\n    # Should get the right zip content type\n    assert res.content_type == \"application/zip\"\n\n    # Should be PK/ZIP stream\n    assert res.data.count(b'PK') >= 2\n\n    backup = ZipFile(io.BytesIO(res.data))\n    l = backup.namelist()\n\n    # Check for UUID-based txt files (history, snapshot, and last-checksum)\n    uuid4hex_txt = re.compile('^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}.*txt', re.I)\n    txt_files = list(filter(uuid4hex_txt.match, l))\n    # Should be three txt files in the archive (history, snapshot, and last-checksum)\n    assert len(txt_files) == 3\n\n    # Check for watch.json files (new format)\n    uuid4hex_json = re.compile('^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}/watch\\.json$', re.I)\n    json_files = list(filter(uuid4hex_json.match, l))\n    # Should be one watch.json file in the archive (the imported watch)\n    assert len(json_files) == 1, f\"Expected 1 watch.json file, found {len(json_files)}: {json_files}\"\n\n    # Check for changedetection.json (settings file)\n    assert 'changedetection.json' in l, \"changedetection.json should be in backup\"\n\n    # secret.txt must never be included — it contains the Flask session key\n    assert 'secret.txt' not in l, \"secret.txt (Flask session key) must not be included in backup\"\n\n    # Get the latest one\n    res = client.get(\n        url_for(\"backups.remove_backups\"),\n        follow_redirects=True\n    )\n\n    assert b'No backups found.' in res.data\n\n\ndef test_watch_data_package_download(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test downloading a single watch's data as a zip package\"\"\"\n\n    set_original_response(datastore_path=datastore_path)\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_endpoint', _external=True))\n    tag_uuid = client.application.config.get('DATASTORE').add_tag(title=\"Tasty backup tag\")\n    tag_uuid2 = client.application.config.get('DATASTORE').add_tag(title=\"Tasty backup tag number two\")\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # Download the watch data package\n    res = client.get(url_for(\"ui.ui_edit.watch_get_data_package\", uuid=uuid))\n\n    # Should get the right zip content type\n    assert res.content_type == \"application/zip\"\n\n    # Should be PK/ZIP stream (PKzip header)\n    assert res.data[:2] == b'PK', \"File should start with PK (PKzip header)\"\n    assert res.data.count(b'PK') >= 2, \"Should have multiple PK markers (zip file structure)\"\n\n    # Verify zip contents\n    backup = ZipFile(io.BytesIO(res.data))\n    files = backup.namelist()\n\n    # Should have files in a UUID directory\n    assert any(uuid in f for f in files), f\"Files should be in UUID directory: {files}\"\n\n    # Should contain watch.json\n    watch_json_path = f\"{uuid}/watch.json\"\n    assert watch_json_path in files, f\"Should contain watch.json, got: {files}\"\n\n    # Should contain history/snapshot files\n    uuid4hex_txt = re.compile(f'^{re.escape(uuid)}/.*\\\\.txt', re.I)\n    txt_files = list(filter(uuid4hex_txt.match, files))\n    assert len(txt_files) > 0, f\"Should have at least one .txt file (history/snapshot), got: {files}\"\n\n\ndef test_backup_restore(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that a full backup zip can be restored — watches and tags survive a round-trip.\"\"\"\n\n    set_original_response(datastore_path=datastore_path)\n\n    datastore = live_server.app.config['DATASTORE']\n    watch_url = url_for('test_endpoint', _external=True)\n\n    # Set up: one watch and two tags\n    uuid = datastore.add_watch(url=watch_url)\n    tag_uuid = datastore.add_tag(title=\"Tasty backup tag\")\n    tag_uuid2 = datastore.add_tag(title=\"Tasty backup tag number two\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Create a full backup\n    client.get(url_for(\"backups.request_backup\"), follow_redirects=True)\n    time.sleep(4)\n\n    # Download the latest backup zip\n    res = client.get(url_for(\"backups.download_backup\", filename=\"latest\"), follow_redirects=True)\n    assert res.content_type == \"application/zip\"\n    zip_data = res.data\n\n    # Confirm the zip contains both watch.json and tag.json entries\n    backup = ZipFile(io.BytesIO(zip_data))\n    names = backup.namelist()\n    assert f\"{uuid}/watch.json\" in names, f\"watch.json missing from backup: {names}\"\n    assert f\"{tag_uuid}/tag.json\" in names, f\"tag.json for tag 1 missing from backup: {names}\"\n    assert f\"{tag_uuid2}/tag.json\" in names, f\"tag.json for tag 2 missing from backup: {names}\"\n\n    # --- Wipe everything ---\n    datastore.delete('all')\n    client.get(url_for(\"tags.delete_all\"), follow_redirects=True)\n\n    assert uuid not in datastore.data['watching'], \"Watch should be gone after delete\"\n    assert tag_uuid not in datastore.data['settings']['application']['tags'], \"Tag 1 should be gone after delete\"\n    assert tag_uuid2 not in datastore.data['settings']['application']['tags'], \"Tag 2 should be gone after delete\"\n\n    # --- Restore from the backup zip ---\n    res = client.post(\n        url_for(\"backups.restore.backups_restore_start\"),\n        data={\n            'zip_file': (io.BytesIO(zip_data), 'backup.zip'),\n            'include_groups': 'y',\n            'include_groups_replace_existing': 'y',\n            'include_watches': 'y',\n            'include_watches_replace_existing': 'y',\n        },\n        content_type='multipart/form-data',\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n\n    # Wait for the thread to finish\n    time.sleep(2)\n\n    # --- Watch checks ---\n    restored_watch = datastore.data['watching'].get(uuid)\n    assert restored_watch is not None, f\"Watch {uuid} not found after restore\"\n    assert restored_watch['url'] == watch_url, \"Restored watch URL does not match\"\n    assert isinstance(restored_watch, Watch.model), \\\n        f\"Watch not properly rehydrated, got {type(restored_watch)}\"\n    assert restored_watch.history_n >= 1, \\\n        f\"Restored watch should have at least 1 history entry, got {restored_watch.history_n}\"\n\n    # --- Tag checks ---\n    restored_tags = datastore.data['settings']['application']['tags']\n\n    restored_tag = restored_tags.get(tag_uuid)\n    assert restored_tag is not None, f\"Tag {tag_uuid} not found after restore\"\n    assert restored_tag['title'] == \"Tasty backup tag\", \"Restored tag 1 title does not match\"\n    assert isinstance(restored_tag, Tag.model), \\\n        f\"Tag 1 not properly rehydrated, got {type(restored_tag)}\"\n\n    restored_tag2 = restored_tags.get(tag_uuid2)\n    assert restored_tag2 is not None, f\"Tag {tag_uuid2} not found after restore\"\n    assert restored_tag2['title'] == \"Tasty backup tag number two\", \"Restored tag 2 title does not match\"\n    assert isinstance(restored_tag2, Tag.model), \\\n        f\"Tag 2 not properly rehydrated, got {type(restored_tag2)}\"\n\n\ndef test_backup_restore_zip_slip_rejected(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Zip Slip path traversal entries in a restore zip must be rejected.\"\"\"\n    import pytest\n    from changedetectionio.blueprint.backups.restore import import_from_zip\n\n    # Build a zip with a path traversal entry that would escape the extraction dir\n    malicious_zip = io.BytesIO()\n    with ZipFile(malicious_zip, 'w') as zf:\n        zf.writestr(\"../escaped.txt\", \"ATTACKER-CONTROLLED\")\n    malicious_zip.seek(0)\n\n    datastore = live_server.app.config['DATASTORE']\n\n    with pytest.raises(ValueError, match=\"Zip Slip\"):\n        import_from_zip(\n            zip_stream=malicious_zip,\n            datastore=datastore,\n            include_groups=True,\n            include_groups_replace=True,\n            include_watches=True,\n            include_watches_replace=True,\n        )\n\n\ndef test_backup_restore_zip_bomb_rejected(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"A zip whose total uncompressed size exceeds the limit must be rejected.\n\n    The guard reads file_size from the zip central-directory metadata — no\n    actual decompression happens, so this test is fast and uses minimal RAM.\n    100 KB of zeros compresses to ~100 bytes; monkeypatching the limit to\n    50 KB is enough to trigger the check without creating any large files.\n    \"\"\"\n    import pytest\n    import changedetectionio.blueprint.backups.restore as restore_mod\n    from changedetectionio.blueprint.backups.restore import import_from_zip\n\n    # ~100 KB of zeros → deflate compresses to ~100 bytes, but file_size metadata = 100 KB\n    bomb_zip = io.BytesIO()\n    with ZipFile(bomb_zip, 'w', compression=ZIP_DEFLATED) as zf:\n        zf.writestr(\"data.txt\", b\"\\x00\" * (100 * 1024))\n    bomb_zip.seek(0)\n\n    datastore = live_server.app.config['DATASTORE']\n    original_limit = restore_mod._MAX_DECOMPRESSED_BYTES\n    try:\n        restore_mod._MAX_DECOMPRESSED_BYTES = 50 * 1024  # 50 KB limit for this test\n        with pytest.raises(ValueError, match=\"decompressed size\"):\n            import_from_zip(\n                zip_stream=bomb_zip,\n                datastore=datastore,\n                include_groups=True,\n                include_groups_replace=True,\n                include_watches=True,\n                include_watches_replace=True,\n            )\n    finally:\n        restore_mod._MAX_DECOMPRESSED_BYTES = original_limit"
  },
  {
    "path": "changedetectionio/tests/test_basic_socketio.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import (\n    set_original_response,\n    set_modified_response,\n    live_server_setup,\n    wait_for_all_checks, delete_all_watches\n)\nfrom loguru import logger\n\ndef run_socketio_watch_update_test(client, live_server, password_mode=\"\", datastore_path=\"\"):\n    \"\"\"Test that the socketio emits a watch update event when content changes\"\"\"\n\n    # Set up the test server\n    set_original_response(datastore_path=datastore_path)\n\n\n    # Get the SocketIO instance from the app\n    from changedetectionio.flask_app import app\n    socketio = app.extensions['socketio']\n\n    # Create a test client for SocketIO\n    socketio_test_client = socketio.test_client(app, flask_test_client=client)\n    if password_mode == \"not logged in, should exit on connect\":\n        assert not socketio_test_client.is_connected(), \"Failed to connect to Socket.IO server because it should bounce this connect\"\n        return\n\n    assert socketio_test_client.is_connected(), \"Failed to connect to Socket.IO server\"\n    print(\"Successfully connected to Socket.IO server\")\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert url_for('test_endpoint', _external=True).encode() in res.data\n\n    # Wait for initial check to complete\n    wait_for_all_checks(client)\n\n    # Clear any initial messages\n    socketio_test_client.get_received()\n\n    # Make a change to trigger an update\n    set_modified_response(datastore_path=datastore_path)\n\n    # Force recheck\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    # Wait for the watch to be checked\n    wait_for_all_checks(client)\n\n    has_watch_update = False\n    has_unviewed_update = False\n    got_general_stats_update = False\n\n    for i in range(10):\n        # Get received events\n        received = socketio_test_client.get_received()\n\n        if received:\n            logger.info(f\"Received {len(received)} events after {i+1} seconds\")\n            for event in received:\n                if event['name'] == 'watch_update':\n                    has_watch_update = True\n                if event['name'] == 'general_stats_update':\n                    got_general_stats_update = True\n\n        if has_unviewed_update:\n            break\n\n        # Force a recheck every 5 seconds to ensure events are emitted\n#        if i > 0 and i % 5 == 0:\n#            print(f\"Still waiting for events, forcing another recheck...\")\n#            res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n#            assert b'Queued 1 watch for rechecking.' in res.data\n#            wait_for_all_checks(client)\n\n#        print(f\"Waiting for unviewed update event... {i+1}/{max_wait}\")\n        time.sleep(1)\n\n    # Verify we received watch_update events\n    assert has_watch_update, \"No watch_update events received\"\n\n    # Verify we received an unviewed event\n    assert got_general_stats_update, \"Got general stats update event\"\n\n    # Alternatively, check directly if the watch in the datastore is marked as unviewed\n    from changedetectionio.flask_app import app\n    datastore = app.config.get('DATASTORE')\n\n    watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    # Get the watch from the datastore\n    watch = datastore.data['watching'].get(watch_uuid)\n    assert watch, f\"Watch {watch_uuid} not found in datastore\"\n    assert watch.has_unviewed, \"The watch was not marked as unviewed after content change\"\n\n    # Clean up\n    delete_all_watches(client)\n\ndef test_everything(live_server, client, measure_memory_usage, datastore_path):\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    run_socketio_watch_update_test(password_mode=\"\", live_server=live_server, client=client, datastore_path=datastore_path)\n\n    ############################ Password required auth check ##############################\n\n    # Enable password check and diff page access bypass\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-password\": \"foobar\",\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Password protection enabled.\" in res.data\n\n    run_socketio_watch_update_test(password_mode=\"not logged in, should exit on connect\", live_server=live_server, client=client, datastore_path=datastore_path)\n    res = client.post(\n        url_for(\"login\"),\n        data={\"password\": \"foobar\"},\n        follow_redirects=True\n    )\n\n    # Yes we are correctly logged in\n    assert b\"LOG OUT\" in res.data\n    run_socketio_watch_update_test(password_mode=\"should be like normal\", live_server=live_server, client=client, datastore_path=datastore_path)\n"
  },
  {
    "path": "changedetectionio/tests/test_block_while_text_present.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nfrom changedetectionio import html_tools\nimport os\n\ndef set_original_ignore_response(datastore_path):\n\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef set_modified_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some NEW nice initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <p>new ignore stuff</p>\n     <p>out of stock</p>\n     <p>blah</p>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n# Is the same but includes ZZZZZ, 'ZZZZZ' is the last line in ignore_text\ndef set_modified_response_minus_block_text(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some NEW nice initial text<br>\n     <p>Which is across multiple lines</p>\n     <p>now on sale $2/p>\n     <br>\n     So let's see what happens.  <br>\n     <p>new ignore stuff</p>\n     <p>blah</p>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef test_check_block_changedetection_text_NOT_present(client, live_server, measure_memory_usage, datastore_path):\n\n    # Use a mix of case in ZzZ to prove it works case-insensitive.\n    ignore_text = \"out of stoCk\\r\\nfoobar\"\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\"text_should_not_be_present\": ignore_text,\n              \"url\": test_url,\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"\n              },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n    )\n    assert bytes(ignore_text.encode('utf-8')) in res.data\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n    assert b'/test-endpoint' in res.data\n\n    # The page changed, BUT the text is still there, just the rest of it changes, we should not see a change\n    set_modified_original_ignore_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n    assert b'/test-endpoint' in res.data\n\n    # 2548\n    # Going back to the ORIGINAL should NOT trigger a change\n    set_original_ignore_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # Now we set a change where the text is gone AND its different content, it should now trigger\n    set_modified_response_minus_block_text(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b'has-unread-changes' in res.data\n\n    # Clearing all history then viewing it should show us what is blocked\n    set_modified_original_ignore_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.clear_watch_history\", uuid=uuid))\n    wait_for_all_checks(client)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuid)\n    )\n    assert b'blocked_line_numbers = [10]' in res.data\n\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_clone.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\nimport os\n\n\ndef test_clone_functionality(client, live_server, measure_memory_usage, datastore_path):\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html><body>Some content</body></html>\")\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # So that we can be sure the same history doesnt carry over\n    time.sleep(1)\n\n    res = client.get(\n        url_for(\"ui.form_clone\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    existing_uuids = set()\n\n    for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items():\n        new_uuids = set(watch.history.keys())\n        duplicates = existing_uuids.intersection(new_uuids)\n        assert len(duplicates) == 0\n        existing_uuids.update(new_uuids)\n\n    assert b\"Cloned\" in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_commit_persistence.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTests for immediate commit-based persistence system.\n\nTests cover:\n- Watch.commit() persistence to disk\n- Concurrent commit safety (race conditions)\n- Processor config separation\n- Data loss prevention (settings, tags, watch modifications)\n\"\"\"\n\nimport json\nimport os\nimport threading\nimport time\nfrom flask import url_for\nfrom .util import wait_for_all_checks\n\n\n# ==============================================================================\n# 2. Commit() Persistence Tests\n# ==============================================================================\n\ndef test_watch_commit_persists_to_disk(client, live_server):\n    \"\"\"Test that watch.commit() actually writes to watch.json immediately\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    # Create a watch\n    uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Original Title'})\n    watch = datastore.data['watching'][uuid]\n\n    # Modify and commit\n    watch['title'] = 'Modified Title'\n    watch['paused'] = True\n    watch.commit()\n\n    # Read directly from disk (bypass datastore cache)\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    assert os.path.exists(watch_json_path), \"watch.json should exist on disk\"\n\n    with open(watch_json_path, 'r') as f:\n        disk_data = json.load(f)\n\n    assert disk_data['title'] == 'Modified Title', \"Title should be persisted to disk\"\n    assert disk_data['paused'] == True, \"Paused state should be persisted to disk\"\n    assert disk_data['uuid'] == uuid, \"UUID should match\"\n\n\ndef test_watch_commit_survives_reload(client, live_server):\n    \"\"\"Test that committed changes survive datastore reload\"\"\"\n    from changedetectionio.store import ChangeDetectionStore\n\n    datastore = client.application.config.get('DATASTORE')\n    datastore_path = datastore.datastore_path\n\n    # Create and modify a watch\n    uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Test Watch'})\n    watch = datastore.data['watching'][uuid]\n    watch['title'] = 'Persisted Title'\n    watch['paused'] = True\n    watch['tags'] = ['tag-1', 'tag-2']\n    watch.commit()\n\n    # Simulate app restart - create new datastore instance\n    datastore2 = ChangeDetectionStore(datastore_path=datastore_path)\n    datastore2.reload_state(\n        datastore_path=datastore_path,\n        include_default_watches=False,\n        version_tag='test'\n    )\n\n    # Check data survived\n    assert uuid in datastore2.data['watching'], \"Watch should exist after reload\"\n    reloaded_watch = datastore2.data['watching'][uuid]\n    assert reloaded_watch['title'] == 'Persisted Title', \"Title should survive reload\"\n    assert reloaded_watch['paused'] == True, \"Paused state should survive reload\"\n    assert reloaded_watch['tags'] == ['tag-1', 'tag-2'], \"Tags should survive reload\"\n\n\ndef test_watch_commit_atomic_on_crash(client, live_server):\n    \"\"\"Test that atomic writes prevent corruption (temp file pattern)\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Original'})\n    watch = datastore.data['watching'][uuid]\n\n    # First successful commit\n    watch['title'] = 'First Save'\n    watch.commit()\n\n    # Verify watch.json exists and is valid\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)  # Should not raise JSONDecodeError\n        assert data['title'] == 'First Save'\n\n    # Second commit - even if interrupted, original file should be intact\n    # (atomic write uses temp file + rename, so original is never corrupted)\n    watch['title'] = 'Second Save'\n    watch.commit()\n\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)\n        assert data['title'] == 'Second Save'\n\n\ndef test_multiple_watches_commit_independently(client, live_server):\n    \"\"\"Test that committing one watch doesn't affect others\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    # Create multiple watches\n    uuid1 = datastore.add_watch(url='http://example1.com', extras={'title': 'Watch 1'})\n    uuid2 = datastore.add_watch(url='http://example2.com', extras={'title': 'Watch 2'})\n    uuid3 = datastore.add_watch(url='http://example3.com', extras={'title': 'Watch 3'})\n\n    watch1 = datastore.data['watching'][uuid1]\n    watch2 = datastore.data['watching'][uuid2]\n    watch3 = datastore.data['watching'][uuid3]\n\n    # Modify and commit only watch2\n    watch2['title'] = 'Modified Watch 2'\n    watch2['paused'] = True\n    watch2.commit()\n\n    # Read all from disk\n    def read_watch_json(uuid):\n        watch = datastore.data['watching'][uuid]\n        path = os.path.join(watch.data_dir, 'watch.json')\n        with open(path, 'r') as f:\n            return json.load(f)\n\n    data1 = read_watch_json(uuid1)\n    data2 = read_watch_json(uuid2)\n    data3 = read_watch_json(uuid3)\n\n    # Only watch2 should have changes\n    assert data1['title'] == 'Watch 1', \"Watch 1 should be unchanged\"\n    assert data1['paused'] == False, \"Watch 1 should not be paused\"\n\n    assert data2['title'] == 'Modified Watch 2', \"Watch 2 should be modified\"\n    assert data2['paused'] == True, \"Watch 2 should be paused\"\n\n    assert data3['title'] == 'Watch 3', \"Watch 3 should be unchanged\"\n    assert data3['paused'] == False, \"Watch 3 should not be paused\"\n\n\n# ==============================================================================\n# 3. Concurrency/Race Condition Tests\n# ==============================================================================\n\ndef test_concurrent_watch_commits_dont_corrupt(client, live_server):\n    \"\"\"Test that simultaneous commits to same watch don't corrupt JSON\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Test'})\n    watch = datastore.data['watching'][uuid]\n\n    errors = []\n\n    def modify_and_commit(field, value):\n        try:\n            watch[field] = value\n            watch.commit()\n        except Exception as e:\n            errors.append(e)\n\n    # Run 10 concurrent commits\n    threads = []\n    for i in range(10):\n        t = threading.Thread(target=modify_and_commit, args=('title', f'Title {i}'))\n        threads.append(t)\n        t.start()\n\n    for t in threads:\n        t.join()\n\n    # Should not have any errors\n    assert len(errors) == 0, f\"Expected no errors, got: {errors}\"\n\n    # JSON file should still be valid (not corrupted)\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)  # Should not raise JSONDecodeError\n        assert data['uuid'] == uuid, \"UUID should still be correct\"\n        assert 'Title' in data['title'], \"Title should contain 'Title'\"\n\n\ndef test_concurrent_modifications_during_commit(client, live_server):\n    \"\"\"Test that modifying watch during commit doesn't cause RuntimeError\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Test'})\n    watch = datastore.data['watching'][uuid]\n\n    errors = []\n    stop_flag = threading.Event()\n\n    def keep_modifying():\n        \"\"\"Continuously modify watch\"\"\"\n        try:\n            i = 0\n            while not stop_flag.is_set():\n                watch['title'] = f'Title {i}'\n                watch['paused'] = i % 2 == 0\n                i += 1\n                time.sleep(0.001)\n        except Exception as e:\n            errors.append(('modifier', e))\n\n    def keep_committing():\n        \"\"\"Continuously commit watch\"\"\"\n        try:\n            for _ in range(20):\n                watch.commit()\n                time.sleep(0.005)\n        except Exception as e:\n            errors.append(('committer', e))\n\n    # Start concurrent modification and commits\n    modifier = threading.Thread(target=keep_modifying)\n    committer = threading.Thread(target=keep_committing)\n\n    modifier.start()\n    committer.start()\n\n    committer.join()\n    stop_flag.set()\n    modifier.join()\n\n    # Should not have RuntimeError from dict changing during iteration\n    runtime_errors = [e for source, e in errors if isinstance(e, RuntimeError)]\n    assert len(runtime_errors) == 0, f\"Should not have RuntimeError, got: {runtime_errors}\"\n\n\ndef test_datastore_lock_protects_commit_snapshot(client, live_server):\n    \"\"\"Test that datastore.lock prevents race conditions during deepcopy\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Test'})\n    watch = datastore.data['watching'][uuid]\n\n    # Add some complex nested data\n    watch['browser_steps'] = [\n        {'operation': 'click', 'selector': '#foo'},\n        {'operation': 'wait', 'seconds': 5}\n    ]\n\n    errors = []\n    commits_succeeded = [0]\n\n    def rapid_commits():\n        try:\n            for i in range(50):\n                watch['title'] = f'Title {i}'\n                watch.commit()\n                commits_succeeded[0] += 1\n                time.sleep(0.001)\n        except Exception as e:\n            errors.append(e)\n\n    # Multiple threads doing rapid commits\n    threads = [threading.Thread(target=rapid_commits) for _ in range(3)]\n\n    for t in threads:\n        t.start()\n    for t in threads:\n        t.join()\n\n    assert len(errors) == 0, f\"Expected no errors, got: {errors}\"\n    assert commits_succeeded[0] == 150, f\"Expected 150 commits, got {commits_succeeded[0]}\"\n\n    # Final JSON should be valid\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)\n        assert data['uuid'] == uuid\n\n\n# ==============================================================================\n# 4. Processor Config Separation Tests\n# ==============================================================================\n\ndef test_processor_config_never_in_watch_json(client, live_server):\n    \"\"\"Test that processor_config_* fields are filtered out of watch.json\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    uuid = datastore.add_watch(\n        url='http://example.com',\n        extras={\n            'title': 'Test Watch',\n            'processor': 'restock_diff'\n        }\n    )\n\n    watch = datastore.data['watching'][uuid]\n\n    # Try to set processor config fields (these should be filtered during commit)\n    watch['processor_config_price_threshold'] = 10.0\n    watch['processor_config_some_setting'] = 'value'\n    watch['processor_config_another'] = {'nested': 'data'}\n    watch.commit()\n\n    # Read watch.json from disk\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)\n\n    # Verify processor_config_* fields are NOT in watch.json\n    for key in data.keys():\n        assert not key.startswith('processor_config_'), \\\n            f\"Found {key} in watch.json - processor configs should be in separate file!\"\n\n    # Normal fields should still be there\n    assert data['title'] == 'Test Watch'\n    assert data['processor'] == 'restock_diff'\n\n\ndef test_api_post_saves_processor_config_separately(client, live_server):\n    \"\"\"Test that API POST saves processor configs to {processor}.json\"\"\"\n    import json\n    from changedetectionio.processors import extract_processor_config_from_form_data\n\n    # Get API key\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Create watch via API with processor config\n    response = client.post(\n        url_for(\"createwatch\"),\n        data=json.dumps({\n            'url': 'http://example.com',\n            'processor': 'restock_diff',\n            'processor_config_price_threshold': 10.0,\n            'processor_config_in_stock_only': True\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n\n    assert response.status_code in (200, 201), f\"Expected 200/201, got {response.status_code}\"\n    uuid = response.json.get('uuid')\n    assert uuid, \"Should return UUID\"\n\n    datastore = client.application.config.get('DATASTORE')\n    watch = datastore.data['watching'][uuid]\n\n    # Check that processor config file exists\n    processor_config_path = os.path.join(watch.data_dir, 'restock_diff.json')\n    assert os.path.exists(processor_config_path), \"Processor config file should exist\"\n\n    with open(processor_config_path, 'r') as f:\n        config = json.load(f)\n\n    # Verify fields are saved WITHOUT processor_config_ prefix\n    assert config.get('price_threshold') == 10.0, \"Should have price_threshold (no prefix)\"\n    assert config.get('in_stock_only') == True, \"Should have in_stock_only (no prefix)\"\n    assert 'processor_config_price_threshold' not in config, \"Should NOT have prefixed keys\"\n\n\ndef test_api_put_saves_processor_config_separately(client, live_server):\n    \"\"\"Test that API PUT updates processor configs in {processor}.json\"\"\"\n    import json\n    datastore = client.application.config.get('DATASTORE')\n\n    # Get API key\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    # Create watch\n    uuid = datastore.add_watch(\n        url='http://example.com',\n        extras={'processor': 'restock_diff'}\n    )\n\n    # Update via API with processor config\n    response = client.put(\n        url_for(\"watch\", uuid=uuid),\n        data=json.dumps({\n            'processor_config_price_threshold': 15.0,\n            'processor_config_min_stock': 5\n        }),\n        headers={'content-type': 'application/json', 'x-api-key': api_key}\n    )\n\n    # PUT might return different status codes, 200 or 204 are both OK\n    assert response.status_code in (200, 204), f\"Expected 200/204, got {response.status_code}: {response.data}\"\n\n    watch = datastore.data['watching'][uuid]\n\n    # Check processor config file\n    processor_config_path = os.path.join(watch.data_dir, 'restock_diff.json')\n    assert os.path.exists(processor_config_path), \"Processor config file should exist\"\n\n    with open(processor_config_path, 'r') as f:\n        config = json.load(f)\n\n    assert config.get('price_threshold') == 15.0, \"Should have updated price_threshold\"\n    assert config.get('min_stock') == 5, \"Should have min_stock\"\n\n\ndef test_ui_edit_saves_processor_config_separately(client, live_server):\n    \"\"\"Test that processor_config_* fields never appear in watch.json (even from UI)\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    # Create watch\n    uuid = datastore.add_watch(\n        url='http://example.com',\n        extras={'processor': 'text_json_diff', 'title': 'Test'}\n    )\n\n    watch = datastore.data['watching'][uuid]\n\n    # Simulate someone accidentally trying to set processor_config fields directly\n    watch['processor_config_should_not_save'] = 'test_value'\n    watch['processor_config_another_field'] = 123\n    watch['normal_field'] = 'this_should_save'\n    watch.commit()\n\n    # Check watch.json has NO processor_config_* fields (main point of this test)\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    with open(watch_json_path, 'r') as f:\n        watch_data = json.load(f)\n\n    for key in watch_data.keys():\n        assert not key.startswith('processor_config_'), \\\n            f\"Found {key} in watch.json - processor configs should be filtered during commit\"\n\n    # Verify normal fields still save\n    assert watch_data['normal_field'] == 'this_should_save', \"Normal fields should save\"\n    assert watch_data['title'] == 'Test', \"Original fields should still be there\"\n\n\ndef test_browser_steps_normalized_to_empty_list(client, live_server):\n    \"\"\"Test that meaningless browser_steps are normalized to [] during commit\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    uuid = datastore.add_watch(url='http://example.com')\n    watch = datastore.data['watching'][uuid]\n\n    # Set browser_steps to meaningless values\n    watch['browser_steps'] = [\n        {'operation': 'Choose one', 'selector': ''},\n        {'operation': 'Goto site', 'selector': ''},\n        {'operation': '', 'selector': '#foo'}\n    ]\n    watch.commit()\n\n    # Read from disk\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)\n\n    # Should be normalized to empty list\n    assert data['browser_steps'] == [], \"Meaningless browser_steps should be normalized to []\"\n\n\n# ==============================================================================\n# 5. Data Loss Prevention Tests\n# ==============================================================================\n\ndef test_settings_persist_after_update(client, live_server):\n    \"\"\"Test that settings updates are committed and survive restart\"\"\"\n    from changedetectionio.store import ChangeDetectionStore\n\n    datastore = client.application.config.get('DATASTORE')\n    datastore_path = datastore.datastore_path\n\n    # Update settings directly (bypass form validation issues)\n    datastore.data['settings']['application']['empty_pages_are_a_change'] = True\n    datastore.data['settings']['application']['fetch_backend'] = 'html_requests'\n    datastore.data['settings']['requests']['time_between_check']['minutes'] = 120\n    datastore.commit()\n\n    # Simulate restart\n    datastore2 = ChangeDetectionStore(datastore_path=datastore_path)\n    datastore2.reload_state(\n        datastore_path=datastore_path,\n        include_default_watches=False,\n        version_tag='test'\n    )\n\n    # Verify settings survived\n    assert datastore2.data['settings']['application']['empty_pages_are_a_change'] == True, \"empty_pages_are_a_change should persist\"\n    assert datastore2.data['settings']['application']['fetch_backend'] == 'html_requests', \"fetch_backend should persist\"\n    assert datastore2.data['settings']['requests']['time_between_check']['minutes'] == 120, \"time_between_check should persist\"\n\n\ndef test_tag_mute_persists(client, live_server):\n    \"\"\"Test that tag mute/unmute operations persist\"\"\"\n    from changedetectionio.store import ChangeDetectionStore\n\n    datastore = client.application.config.get('DATASTORE')\n    datastore_path = datastore.datastore_path\n\n    # Add a tag\n    tag_uuid = datastore.add_tag('Test Tag')\n\n    # Mute the tag\n    response = client.get(url_for(\"tags.mute\", uuid=tag_uuid))\n    assert response.status_code == 302  # Redirect\n\n    # Verify muted in memory\n    assert datastore.data['settings']['application']['tags'][tag_uuid]['notification_muted'] == True\n\n    # Simulate restart\n    datastore2 = ChangeDetectionStore(datastore_path=datastore_path)\n    datastore2.reload_state(\n        datastore_path=datastore_path,\n        include_default_watches=False,\n        version_tag='test'\n    )\n\n    # Verify mute state survived\n    assert tag_uuid in datastore2.data['settings']['application']['tags']\n    assert datastore2.data['settings']['application']['tags'][tag_uuid]['notification_muted'] == True\n\n\ndef test_tag_delete_removes_from_watches(client, live_server):\n    \"\"\"Test that deleting a tag removes it from all watches\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    # Create a tag\n    tag_uuid = datastore.add_tag('Test Tag')\n\n    # Create watches with this tag\n    uuid1 = datastore.add_watch(url='http://example1.com')\n    uuid2 = datastore.add_watch(url='http://example2.com')\n    uuid3 = datastore.add_watch(url='http://example3.com')\n\n    watch1 = datastore.data['watching'][uuid1]\n    watch2 = datastore.data['watching'][uuid2]\n    watch3 = datastore.data['watching'][uuid3]\n\n    watch1['tags'] = [tag_uuid]\n    watch1.commit()\n    watch2['tags'] = [tag_uuid, 'other-tag']\n    watch2.commit()\n    # watch3 has no tags\n\n    # Delete the tag\n    response = client.get(url_for(\"tags.delete\", uuid=tag_uuid))\n    assert response.status_code == 302\n\n    # Wait for background thread to complete\n    time.sleep(1)\n\n    # Tag should be removed from settings\n    assert tag_uuid not in datastore.data['settings']['application']['tags']\n\n    # Tag should be removed from watches and persisted\n    def check_watch_tags(uuid):\n        watch = datastore.data['watching'][uuid]\n        watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n        with open(watch_json_path, 'r') as f:\n            return json.load(f)['tags']\n\n    assert tag_uuid not in check_watch_tags(uuid1), \"Tag should be removed from watch1\"\n    assert tag_uuid not in check_watch_tags(uuid2), \"Tag should be removed from watch2\"\n    assert 'other-tag' in check_watch_tags(uuid2), \"Other tags should remain in watch2\"\n    assert check_watch_tags(uuid3) == [], \"Watch3 should still have empty tags\"\n\n\ndef test_watch_pause_unpause_persists(client, live_server):\n    \"\"\"Test that pause/unpause operations commit and persist\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    # Get API key\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    uuid = datastore.add_watch(url='http://example.com')\n    watch = datastore.data['watching'][uuid]\n\n    # Pause via API\n    response = client.get(url_for(\"watch\", uuid=uuid, paused='paused'), headers={'x-api-key': api_key})\n    assert response.status_code == 200\n\n    # Check persisted to disk\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)\n    assert data['paused'] == True, \"Pause should be persisted\"\n\n    # Unpause\n    response = client.get(url_for(\"watch\", uuid=uuid, paused='unpaused'), headers={'x-api-key': api_key})\n    assert response.status_code == 200\n\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)\n    assert data['paused'] == False, \"Unpause should be persisted\"\n\n\ndef test_watch_mute_unmute_persists(client, live_server):\n    \"\"\"Test that mute/unmute operations commit and persist\"\"\"\n    datastore = client.application.config.get('DATASTORE')\n\n    # Get API key\n    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')\n\n    uuid = datastore.add_watch(url='http://example.com')\n    watch = datastore.data['watching'][uuid]\n\n    # Mute via API\n    response = client.get(url_for(\"watch\", uuid=uuid, muted='muted'), headers={'x-api-key': api_key})\n    assert response.status_code == 200\n\n    # Check persisted to disk\n    watch_json_path = os.path.join(watch.data_dir, 'watch.json')\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)\n    assert data['notification_muted'] == True, \"Mute should be persisted\"\n\n    # Unmute\n    response = client.get(url_for(\"watch\", uuid=uuid, muted='unmuted'), headers={'x-api-key': api_key})\n    assert response.status_code == 200\n\n    with open(watch_json_path, 'r') as f:\n        data = json.load(f)\n    assert data['notification_muted'] == False, \"Unmute should be persisted\"\n\n\ndef test_ui_watch_edit_persists_all_fields(client, live_server):\n    \"\"\"Test that UI watch edit form persists all modified fields\"\"\"\n    from changedetectionio.store import ChangeDetectionStore\n\n    datastore = client.application.config.get('DATASTORE')\n    datastore_path = datastore.datastore_path\n\n    # Create watch\n    uuid = datastore.add_watch(url='http://example.com')\n\n    # Edit via UI with multiple field changes\n    response = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\n            'url': 'http://updated-example.com',\n            'title': 'Updated Watch Title',\n            'time_between_check-hours': '2',\n            'time_between_check-minutes': '30',\n            'include_filters': '#content',\n            'fetch_backend': 'html_requests',\n            'method': 'POST',\n            'ignore_text': 'Advertisement\\nTracking'\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch\" in response.data or b\"Saved\" in response.data\n\n    # Simulate restart\n    datastore2 = ChangeDetectionStore(datastore_path=datastore_path)\n    datastore2.reload_state(\n        datastore_path=datastore_path,\n        include_default_watches=False,\n        version_tag='test'\n    )\n\n    # Verify all fields survived\n    watch = datastore2.data['watching'][uuid]\n    assert watch['url'] == 'http://updated-example.com'\n    assert watch['title'] == 'Updated Watch Title'\n    assert watch['time_between_check']['hours'] == 2\n    assert watch['time_between_check']['minutes'] == 30\n    assert watch['fetch_backend'] == 'html_requests'\n    assert watch['method'] == 'POST'\n"
  },
  {
    "path": "changedetectionio/tests/test_conditions.py",
    "content": "#!/usr/bin/env python3\nimport json\nimport time\nimport os\n\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nfrom ..model import CONDITIONS_MATCH_LOGIC_DEFAULT\n\n\ndef set_original_response(datastore_path, number=\"50\"):\n    test_return_data = f\"\"\"<html>\n       <body>\n     <h1>Test Page for Conditions</h1>\n     <p>This page contains a number that will be tested with conditions.</p>\n     <div class=\"number-container\">Current value: {number}</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\ndef set_number_in_range_response(datastore_path, number=\"75\"):\n    test_return_data = f\"\"\"<html>\n       <body>\n     <h1>Test Page for Conditions</h1>\n     <p>This page contains a number that will be tested with conditions.</p>\n     <div class=\"number-container\">Current value: {number}</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\ndef set_number_out_of_range_response(datastore_path, number=\"150\"):\n    test_return_data = f\"\"\"<html>\n       <body>\n     <h1>Test Page for Conditions</h1>\n     <p>This page contains a number that will be tested with conditions.</p>\n     <div class=\"number-container\">Current value: {number}</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that both text and number conditions work together with AND logic.\"\"\"\n   #  live_server_setup(live_server) # Setup on conftest per function\n\ndef test_conditions_with_text_and_number(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that both text and number conditions work together with AND logic.\"\"\"\n    \n    set_original_response(datastore_path=datastore_path, number=\"50\")\n    \n\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Configure the watch with two conditions connected with AND:\n    # 1. The page filtered text must contain \"5\" (first digit of value)\n    # 2. The extracted number should be >= 20 and <= 100\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\n            \"url\": test_url,\n            \"fetch_backend\": \"html_requests\",\n            \"include_filters\": \".number-container\",\n            \"title\": \"Number AND Text Condition Test\",\n            \"conditions_match_logic\": CONDITIONS_MATCH_LOGIC_DEFAULT,  # ALL = AND logic\n            \"conditions-0-operator\": \"in\",\n            \"conditions-0-field\": \"page_filtered_text\",\n            \"conditions-0-value\": \"5\",\n\n            \"conditions-1-operator\": \">=\",\n            \"conditions-1-field\": \"extracted_number\",\n            \"conditions-1-value\": \"20\",\n\n            \"conditions-2-operator\": \"<=\",\n            \"conditions-2-field\": \"extracted_number\",\n            \"conditions-2-value\": \"100\",\n\n            # So that 'operations' from pluggy discovery are tested\n            \"conditions-3-operator\": \"length_min\",\n            \"conditions-3-field\": \"page_filtered_text\",\n            \"conditions-3-value\": \"1\",\n\n            # So that 'operations' from pluggy discovery are tested\n            \"conditions-4-operator\": \"length_max\",\n            \"conditions-4-field\": \"page_filtered_text\",\n            \"conditions-4-value\": \"100\",\n\n            # So that 'operations' from pluggy discovery are tested\n            \"conditions-5-operator\": \"contains_regex\",\n            \"conditions-5-field\": \"page_filtered_text\",\n            \"conditions-5-value\": \"\\d\",\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    wait_for_all_checks(client)\n    client.get(url_for(\"ui.mark_all_viewed\"), follow_redirects=True)\n    time.sleep(1)\n\n    # Case 1\n    set_number_in_range_response(datastore_path=datastore_path, number=\"70.5\")\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # 75 is > 20 and < 100 and contains \"5\"\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    # Case 2: Change with one condition violated\n    # Number out of range (150) but contains '5'\n    client.get(url_for(\"ui.mark_all_viewed\"), follow_redirects=True)\n\n    set_number_out_of_range_response(datastore_path=datastore_path, number=\"150.5\")\n\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Should NOT be marked as having changes since not all conditions are met\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    delete_all_watches(client)\n\n# The 'validate' button next to each rule row\ndef test_condition_validate_rule_row(client, live_server, measure_memory_usage, datastore_path):\n\n    set_original_response(datastore_path=datastore_path, number=\"50\")\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n\n    # the front end submits the current form state which should override the watch in a temporary copy\n    res = client.post(\n        url_for(\"conditions.verify_condition_single_rule\", watch_uuid=uuid),  # Base URL\n        query_string={\"rule\": json.dumps({\"field\": \"extracted_number\", \"operator\": \"==\", \"value\": \"50\"})},\n        data={'include_filter': \"\"},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b'success' in res.data\n\n    # Now a number that does not equal what is found in the last fetch\n    res = client.post(\n        url_for(\"conditions.verify_condition_single_rule\", watch_uuid=uuid),  # Base URL\n        query_string={\"rule\": json.dumps({\"field\": \"extracted_number\", \"operator\": \"==\", \"value\": \"111111\"})},\n        data={'include_filter': \"\"},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b'false' in res.data\n\n    # Now custom filter that exists\n    res = client.post(\n        url_for(\"conditions.verify_condition_single_rule\", watch_uuid=uuid),  # Base URL\n        query_string={\"rule\": json.dumps({\"field\": \"extracted_number\", \"operator\": \"==\", \"value\": \"50\"})},\n        data={'include_filter': \".number-container\"},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b'success' in res.data\n\n    # Now custom filter that DOES NOT exists\n    res = client.post(\n        url_for(\"conditions.verify_condition_single_rule\", watch_uuid=uuid),  # Base URL\n        query_string={\"rule\": json.dumps({\"field\": \"extracted_number\", \"operator\": \"==\", \"value\": \"50\"})},\n        data={'include_filters': \".NOT-container\"},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b'false' in res.data\n\n    delete_all_watches(client)\n\n\n# If there was only a change in the whitespacing, then we shouldnt have a change detected\ndef test_wordcount_conditions_plugin(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n    )\n\n    # Assert the word count is counted correctly\n    assert b'<td>13</td>' in res.data\n    delete_all_watches(client)\n\n# If there was only a change in the whitespacing, then we shouldnt have a change detected\ndef test_lev_conditions_plugin(client, live_server, measure_memory_usage, datastore_path):\n    # This should break..\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\")\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid, unpause_on_save=1),\n        data={\n            \"url\": test_url,\n            \"fetch_backend\": \"html_requests\",\n            \"conditions_match_logic\": CONDITIONS_MATCH_LOGIC_DEFAULT,  # ALL = AND logic\n            \"conditions-0-field\": \"levenshtein_ratio\",\n            \"conditions-0-operator\": \"<\",\n            \"conditions-0-value\": \"0.8\", # needs to be more of a diff to trigger a change\n            \"time_between_check_use_default\": \"y\"\n        },\n        follow_redirects=True\n    )\n\n    assert b\"unpaused\" in res.data\n\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # Check the content saved initially, even tho a condition was set - this is the first snapshot so shouldnt be affected by conditions\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuid),\n        follow_redirects=True\n    )\n    assert b'Which is across multiple lines' in res.data\n\n\n    ############### Now change it a LITTLE bit...\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happenxxxxxxxxx.  <br>\n     </body>\n     </html>\n    \"\"\")\n\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data #because this will be like 0.90 not 0.8 threshold\n\n    ############### Now change it a MORE THAN 50%\n    test_return_data = \"\"\"<html>\n       <body>\n     Some sxxxx<br>\n     <p>Which is across a lines</p>\n     <br>\n     ok.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n    # cleanup for the next\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )"
  },
  {
    "path": "changedetectionio/tests/test_css_selector.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\nimport os\n\nfrom ..html_tools import *\n\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div id=\"sametext\">Some text thats the same</div>\n     <div id=\"changetext\">Some text that will change</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>which has this one new line</p>\n     <br>\n     So let's see what happens.  <br>\n     <div id=\"sametext\">Some text thats the same</div>\n     <div id=\"changetext\">Some text that changes</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    return None\n\n\n# Test that the CSS extraction works how we expect, important here is the right placing of new lines \\n's\ndef test_include_filters_output():\n    from inscriptis import get_text\n\n    # Check text with sub-parts renders correctly\n    content = \"\"\"<html> <body><div id=\"thingthing\" >  Some really <b>bold</b> text  </div> </body> </html>\"\"\"\n    html_blob = include_filters(include_filters=\"#thingthing\", html_content=content)\n    text = get_text(html_blob)\n    assert text == \"  Some really bold text\"\n\n    content = \"\"\"<html> <body>\n    <p>foo bar blah</p>\n    <DIV class=\"parts\">Block A</DiV> <div class=\"parts\">Block B</DIV></body> \n    </html>\n\"\"\"\n\n    # in xPath this would be //*[@class='parts']\n    html_blob = include_filters(include_filters=\".parts\", html_content=content)\n    text = get_text(html_blob)\n\n    # Divs are converted to 4 whitespaces by inscriptis\n    assert text == \"    Block A\\n    Block B\"\n\n\n# Tests the whole stack works with the CSS Filter\ndef test_check_markup_include_filters_restriction(client, live_server, measure_memory_usage, datastore_path):\n\n    include_filters = \"#sametext\"\n\n    set_original_response(datastore_path=datastore_path)\n\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": include_filters, \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    time.sleep(1)\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n    )\n    assert bytes(include_filters.encode('utf-8')) in res.data\n\n    wait_for_all_checks(client)\n\n    #  Make a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n\n    # It should have 'has-unread-changes' still\n    # Because it should be looking at only that 'sametext' id\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n\n# Tests the whole stack works with the CSS Filter\ndef test_check_multiple_filters(client, live_server, measure_memory_usage, datastore_path):\n    \n    include_filters = \"#blob-a\\r\\nxpath://*[contains(@id,'blob-b')]\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"<html><body>\n     <div id=\"blob-a\">Blob A</div>\n     <div id=\"blob-b\">Blob B</div>\n     <div id=\"blob-c\">Blob C</div>\n     </body>\n     </html>\n    \"\"\")\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": include_filters,\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # Only the two blobs should be here\n    assert b\"Blob A\" in res.data # CSS was ok\n    assert b\"Blob B\" in res.data # xPath was ok\n    assert b\"Blob C\" not in res.data # Should not be included\n\n# The filter exists, but did not contain anything useful\n# Mainly used when the filter contains just an IMG, this can happen when someone selects an image in the visual-selector\n# Tests fetcher can throw a \"ReplyWithContentButNoText\" exception after applying filter and extracting text\ndef test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    include_filters = \"#blob-a\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"<html><body>\n         <div id=\"blob-a\">\n           <img src=\"something.jpg\">\n         </div>\n         </body>\n         </html>\n        \"\"\")\n\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": include_filters,\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    wait_for_all_checks(client)\n\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        follow_redirects=True\n    )\n\n    assert b'empty result or contain only an image' in res.data\n\n\n    ### Just an empty selector, no image\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"<html><body>\n         <div id=\"blob-a\">\n           <!-- doo doo -->\n         </div>\n         </body>\n         </html>\n        \"\"\")\n\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        follow_redirects=True\n    )\n\n    assert b'empty result or contain only an image' not in res.data\n    assert b'but contained no usable text' in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_datastore_isolation.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Test to verify client and live_server share the same datastore\"\"\"\n\ndef test_client_and_live_server_share_datastore(client, live_server):\n    \"\"\"Verify that client and live_server use the same app and datastore.\"\"\"\n\n    # They should be the SAME object\n    assert client.application is live_server.app, \"client.application and live_server.app should be the SAME object!\"\n\n    # They should share the same datastore\n    client_datastore = client.application.config.get('DATASTORE')\n    server_datastore = live_server.app.config.get('DATASTORE')\n\n    assert client_datastore is server_datastore, \\\n        f\"Datastores are DIFFERENT objects! client={hex(id(client_datastore))} server={hex(id(server_datastore))}\"\n\n    print(f\"✓ client.application and live_server.app are the SAME object\")\n    print(f\"✓ Both use the same DATASTORE at {hex(id(client_datastore))}\")\n    print(f\"✓ Datastore path: {client_datastore.datastore_path}\")\n"
  },
  {
    "path": "changedetectionio/tests/test_element_removal.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nimport os\n\nfrom flask import url_for\n\nfrom ..html_tools import *\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\n\n\n\n\ndef set_response_with_multiple_index(datastore_path):\n    data= \"\"\"<!DOCTYPE html>\n<html>\n<body>\n\n<!-- NOTE!! CHROME WILL ADD TBODY HERE IF ITS NOT THERE!! -->\n<table style=\"width:100%\">\n  <tr>\n    <th>Person 1</th>\n    <th>Person 2</th>\n    <th>Person 3</th>\n  </tr>\n  <tr>\n    <td>Emil</td>\n    <td>Tobias</td>\n    <td>Linus</td>\n  </tr>\n  <tr>\n    <td>16</td>\n    <td>14</td>\n    <td>10</td>\n  </tr>\n</table>\n</body>\n</html>\n\"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(data)\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n    <header>\n    <h2>Header</h2>\n    </header>\n    <nav>\n    <ul>\n      <li><a href=\"#\">A</a></li>\n      <li><a href=\"#\">B</a></li>\n      <li><a href=\"#\">C</a></li>\n    </ul>\n    </nav>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n    <div id=\"changetext\">Some text that will change</div>\n     </body>\n    <footer>\n    <p>Footer</p>\n    </footer>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n    <header>\n    <h2>Header changed</h2>\n    </header>\n    <nav>\n    <ul>\n      <li><a href=\"#\">A changed</a></li>\n      <li><a href=\"#\">B</a></li>\n      <li><a href=\"#\">C</a></li>\n    </ul>\n    </nav>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n    <div id=\"changetext\">Some text that changes</div>\n     </body>\n    <footer>\n    <p>Footer changed</p>\n    </footer>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef test_element_removal_output():\n    from inscriptis import get_text\n\n    # Check text with sub-parts renders correctly\n    content = \"\"\"<html>\n    <header>\n    <h2>Header</h2>\n    </header>\n    <nav>\n    <ul>\n      <li><a href=\"#\">A</a></li>\n    </ul>\n    </nav>\n       <body>\n     Some initial text<br>\n     <p>across multiple lines</p>\n     <div id=\"changetext\">Some text that changes</div>\n     <div>Some text should be matched by xPath // selector</div>\n     <div>Some text should be matched by xPath selector</div>\n     <div>Some text should be matched by xPath1 selector</div>\n     </body>\n    <footer>\n    <p>Footer</p>\n    </footer>\n     </html>\n    \"\"\"\n    html_blob = element_removal(\n      [\n        \"header\",\n        \"footer\",\n        \"nav\",\n        \"#changetext\",\n        \"//*[contains(text(), 'xPath // selector')]\",\n        \"xpath://*[contains(text(), 'xPath selector')]\",\n        \"xpath1://*[contains(text(), 'xPath1 selector')]\"\n      ],\n      html_content=content\n    )\n    text = get_text(html_blob)\n    assert (\n        text\n        == \"\"\"Some initial text\n\nacross multiple lines\n\"\"\"\n    )\n\n\ndef test_element_removal_full(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    set_original_response(datastore_path=datastore_path)\n\n\n    # Add our URL to the import page\n    test_url = url_for(\"test_endpoint\", _external=True)\n    res = client.post(\n        url_for(\"imports.import_page\"), data={\"urls\": test_url}, follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add the filter data\n    # Not sure why \\r needs to be added - absent of the #changetext this is not necessary\n    subtractive_selectors_data = \"header\\r\\nfooter\\r\\nnav\\r\\n#changetext\"\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"subtractive_selectors\": subtractive_selectors_data,\n            \"url\": test_url,\n            \"tags\": \"\",\n            \"headers\": \"\",\n            \"fetch_backend\": \"html_requests\",\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True,\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n    )\n    assert bytes(subtractive_selectors_data.encode(\"utf-8\")) in res.data\n\n    # Trigger a check\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n\n    # so that we set the state to 'has-unread-changes' after all the edits\n    client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n\n    #  Make a change to header/footer/nav\n    set_modified_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # There should not be an unviewed change, as changes should be removed\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b\"unviewed\" not in res.data\n\n# Re #2752\ndef test_element_removal_nth_offset_no_shift(client, live_server, measure_memory_usage, datastore_path):\n\n    set_response_with_multiple_index(datastore_path=datastore_path)\n    subtractive_selectors_data = [\n### css style ###\n\"\"\"body > table > tr:nth-child(1) > th:nth-child(2)\nbody > table >  tr:nth-child(2) > td:nth-child(2)\nbody > table > tr:nth-child(3) > td:nth-child(2)\nbody > table > tr:nth-child(1) > th:nth-child(3)\nbody > table >  tr:nth-child(2) > td:nth-child(3)\nbody > table > tr:nth-child(3) > td:nth-child(3)\"\"\",\n### second type, xpath ###\n\"\"\"//body/table/tr[1]/th[2]\n//body/table/tr[2]/td[2]\n//body/table/tr[3]/td[2]\n//body/table/tr[1]/th[3]\n//body/table/tr[2]/td[3]\n//body/table/tr[3]/td[3]\"\"\"]\n    \n    test_url = url_for(\"test_endpoint\", _external=True)\n\n    for selector_list in subtractive_selectors_data:\n\n        delete_all_watches(client)\n\n        uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={\"subtractive_selectors\": selector_list.splitlines()})\n        client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        wait_for_all_checks(client)\n\n        res = client.get(\n            url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n            follow_redirects=True\n        )\n\n        # the filters above should have removed this but they never say to remove the \"emil\" column\n        assert b\"Tobias\" not in res.data\n        assert b\"Linus\" not in res.data\n        assert b\"Person 2\" not in res.data\n        assert b\"Person 3\" not in res.data\n        # First column should exist\n        assert b\"Emil\" in res.data\n\n"
  },
  {
    "path": "changedetectionio/tests/test_encoding.py",
    "content": "#!/usr/bin/env python3\n# coding=utf-8\n\nimport hashlib\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client\nimport pytest\nimport os\n\n\n\n\n\ndef test_surrogate_characters_in_content_are_sanitized():\n    \"\"\"Lone surrogates can appear in requests' r.text when a server returns malformed/mixed-encoding\n    content. Without sanitization, encoding to UTF-8 raises UnicodeEncodeError.\n    See: https://github.com/dgtlmoon/changedetection.io/issues/3952\n    \"\"\"\n    content_with_surrogate = '<html><body>Hello \\udcad World</body></html>'\n\n    # Confirm the raw problem exists\n    with pytest.raises(UnicodeEncodeError):\n        content_with_surrogate.encode('utf-8')\n\n    # Our fix: sanitize after fetcher.run() in processors/base.py call_browser()\n    sanitized = content_with_surrogate.encode('utf-8', errors='replace').decode('utf-8')\n    assert 'Hello' in sanitized\n    assert 'World' in sanitized\n    assert '\\udcad' not in sanitized\n\n    # Checksum computation (processors/base.py get_raw_document_checksum) must not crash\n    hashlib.md5(sanitized.encode('utf-8')).hexdigest()\n\n\ndef test_utf8_content_without_charset_header(client, live_server, datastore_path):\n    \"\"\"Server returns UTF-8 content but no charset in Content-Type header.\n    chardet can misdetect such pages as UTF-7 (Python 3.14 then produces surrogates).\n    Our fix tries UTF-8 first before falling back to chardet.\n    See: https://github.com/dgtlmoon/changedetection.io/issues/3952\n    \"\"\"\n    from .util import write_test_file_and_sync\n    # UTF-8 encoded content with non-ASCII chars - no charset will be in the header\n    html = '<html><body><p>Español</p><p>Français</p><p>日本語</p></body></html>'\n    write_test_file_and_sync(os.path.join(datastore_path, \"endpoint-content.txt\"), html.encode('utf-8'), mode='wb')\n\n    test_url = url_for('test_endpoint', content_type=\"text/html\", _external=True)\n    client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"), follow_redirects=True)\n    # Should decode correctly as UTF-8, not produce mojibake (EspaÃ±ol) or replacement chars\n    assert 'Español'.encode('utf-8') in res.data\n    assert 'Français'.encode('utf-8') in res.data\n    assert '日本語'.encode('utf-8') in res.data\n\n\ndef test_shiftjis_with_meta_charset(client, live_server, datastore_path):\n    \"\"\"Server returns Shift-JIS content with no charset in HTTP header, but the HTML\n    declares <meta charset=\"Shift-JIS\">. We should use the meta tag, not chardet.\n    Real-world case: https://github.com/dgtlmoon/changedetection.io/issues/3952\n    \"\"\"\n    from .util import write_test_file_and_sync\n    japanese_text = '日本語のページ'\n    html = f'<html><head><meta http-equiv=\"Content-Type\" content=\"text/html;charset=Shift-JIS\"></head><body><p>{japanese_text}</p></body></html>'\n    write_test_file_and_sync(os.path.join(datastore_path, \"endpoint-content.txt\"), html.encode('shift_jis'), mode='wb')\n\n    test_url = url_for('test_endpoint', content_type=\"text/html\", _external=True)\n    client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"), follow_redirects=True)\n    assert japanese_text.encode('utf-8') in res.data\n\n\ndef set_html_response(datastore_path):\n    test_return_data = \"\"\"\n<html><body><span class=\"nav_second_img_text\">\n                  &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;铸大国重器，挺制造脊梁，致力能源未来，赋能美好生活。\n                                  </span>\n</body></html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\n# In the case the server does not issue a charset= or doesnt have content_type header set\ndef test_check_encoding_detection(client, live_server, measure_memory_usage, datastore_path):\n    set_html_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"text/html\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n\n    # Content type recording worked\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == \"text/html\"\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # Should see the proper string\n    assert \"铸大国重\".encode('utf-8') in res.data\n    # Should not see the failed encoding\n    assert b'\\xc2\\xa7' not in res.data\n\n\n# In the case the server does not issue a charset= or doesnt have content_type header set\ndef test_check_encoding_detection_missing_content_type_header(client, live_server, measure_memory_usage, datastore_path):\n    set_html_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # Should see the proper string\n    assert \"铸大国重\".encode('utf-8') in res.data\n    # Should not see the failed encoding\n    assert b'\\xc2\\xa7' not in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_errorhandling.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nimport os\n\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\n\n\n\n\ndef _runner_test_http_errors(client, live_server, http_code, expected_text, datastore_path):\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"Now you going to get a {} error code\\n\".format(http_code))\n\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint',\n                       status_code=http_code,\n                       _external=True)\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    # no change\n    assert b'has-unread-changes' not in res.data\n    assert bytes(expected_text.encode('utf-8')) in res.data\n\n\n    # Error viewing tabs should appear\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b'Error Text' in res.data\n\n    # 'Error Screenshot' only when in playwright mode\n    #assert b'Error Screenshot' in res.data\n\n\n    delete_all_watches(client)\n\n\ndef test_http_error_handler(client, live_server, measure_memory_usage, datastore_path):\n    _runner_test_http_errors(client, live_server, 403, 'Access denied', datastore_path=datastore_path)\n    _runner_test_http_errors(client, live_server, 404, 'Page not found', datastore_path=datastore_path)\n    _runner_test_http_errors(client, live_server, 500, '(Internal server error) received', datastore_path=datastore_path)\n    _runner_test_http_errors(client, live_server, 400, 'Error - Request returned a HTTP error code 400', datastore_path=datastore_path)\n    delete_all_watches(client)\n\n# Just to be sure error text is properly handled\ndef test_DNS_errors(client, live_server, measure_memory_usage, datastore_path):\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": \"https://errorfuldomainthatnevereallyexists12356.com\"},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    found_name_resolution_error = (\n        b\"No address found\" in res.data or\n        b\"Name or service not known\" in res.data or\n        b\"nodename nor servname provided\" in res.data or\n        b\"Temporary failure in name resolution\" in res.data or\n        b\"Failed to establish a new connection\" in res.data or\n        b\"Connection error occurred\" in res.data\n    )\n    assert found_name_resolution_error\n    # Should always record that we tried\n    assert \"just now\".encode('utf-8') in res.data or 'seconds ago'.encode('utf-8') in res.data\n    delete_all_watches(client)\n\n# Re 1513\ndef test_low_level_errors_clear_correctly(client, live_server, measure_memory_usage, datastore_path):\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html><body><div id=here>Hello world</div></body></html>\")\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": \"https://dfkjasdkfjaidjfsdajfksdajfksdjfDOESNTEXIST.com\"},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # We should see the DNS error\n    res = client.get(url_for(\"watchlist.index\"))\n    found_name_resolution_error = (\n        b\"No address found\" in res.data or\n        b\"Name or service not known\" in res.data or\n        b\"nodename nor servname provided\" in res.data or\n        b\"Temporary failure in name resolution\" in res.data or\n        b\"Failed to establish a new connection\" in res.data or\n        b\"Connection error occurred\" in res.data\n    )\n    assert found_name_resolution_error\n\n    # Update with what should work\n    client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            \"fetch_backend\": \"html_requests\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    # Now the error should be gone\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    found_name_resolution_error = (\n        b\"No address found\" in res.data or\n        b\"Name or service not known\" in res.data or\n        b\"nodename nor servname provided\" in res.data or\n        b\"Temporary failure in name resolution\" in res.data or\n        b\"Failed to establish a new connection\" in res.data or\n        b\"Connection error occurred\" in res.data\n    )\n    assert not found_name_resolution_error\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_extract_csv.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom urllib.request import urlopen\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks\nimport os\n\ndef test_check_extract_text_from_diff(client, live_server, measure_memory_usage, datastore_path):\n    import time\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"Now it's {} seconds since epoch, time flies!\".format(str(time.time())))\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"ui.ui_diff.diff_history_page_extract_GET\", uuid=\"first\"))\n    assert res.status_code == 200\n    assert b'extract_regex' in res.data\n\n    # Load in 5 different numbers/changes\n    last_date=\"\"\n    for n in range(5):\n        time.sleep(1)\n        # Give the thread time to pick it up\n        print(\"Bumping snapshot and checking.. \", n)\n        last_date = str(time.time())\n        with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n            f.write(\"Now it's {} seconds since epoch, time flies!\".format(last_date))\n\n        client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        wait_for_all_checks(client)\n\n\n\n    res = client.post(\n        url_for(\"ui.ui_diff.diff_history_page_extract_POST\", uuid=\"first\"),\n        data={\"extract_regex\": \"Now it's ([0-9\\.]+)\",\n              \"extract_submit_button\": \"Extract as CSV\"},\n        follow_redirects=False\n    )\n\n    assert b'No matches found while scanning all of the watch history for that RegEx.' not in res.data\n    assert res.content_type == 'text/csv'\n\n    # Read the csv reply as stringio\n    from io import StringIO\n    import csv\n\n    f = StringIO(res.data.decode('utf-8'))\n    reader = csv.reader(f, delimiter=',')\n    output=[]\n\n    for row in reader:\n        output.append(row)\n\n    assert output[0][0] == 'Epoch seconds'\n\n    # Header line + 1 origin/first + 5 changes\n    assert(len(output) == 7)\n\n    # We expect to find the last bumped date in the changes in the last field of the spreadsheet\n    assert(output[6][2] == last_date)\n    # And nothing else, only that group () of the decimal and .\n    assert \"time flies\" not in output[6][2]\n"
  },
  {
    "path": "changedetectionio/tests/test_extract_regex.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nimport os\n\nfrom ..html_tools import *\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div id=\"sametext\">Some text thats the same</div>\n     <div class=\"changetext\">Some text that will change</div>     \n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>which has this one new line</p>\n     <br>\n     So let's see what happens.  <br>\n     <div id=\"sametext\">Some text thats the same</div>\n     <div class=\"changetext\">Some text that did change ( 1000 online <br> 80 guests<br>  2000 online )</div>\n     <div class=\"changetext\">SomeCase insensitive 3456</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    return None\n\n\ndef set_multiline_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     \n     <p>Something <br>\n        across 6 billion multiple<br>\n        lines\n     </p>\n     \n     <div>aaand something lines</div>\n     <br>\n     <div>and this should be</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    return None\n\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\ndef test_check_filter_multiline(client, live_server, measure_memory_usage, datastore_path):\n   ##  live_server_setup(live_server) # Setup on conftest per function\n    set_multiline_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": '',\n              # Test a regex and a plaintext\n              'extract_text': '/something.+?6 billion.+?lines/si\\r\\nand this should be',\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"\n              },\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n\n    # Issue 1828\n    assert b'not at the start of the expression' not in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    # Plaintext that doesnt look like a regex should match also\n    assert b'and this should be' in res.data\n\n    assert b'Something' in res.data\n    assert b'across 6 billion multiple' in res.data\n    assert b'lines' in res.data\n\n    # but the last one, which also says 'lines' shouldnt be here (non-greedy match checking)\n    assert b'aaand something lines' not in res.data\n\ndef test_check_filter_and_regex_extract(client, live_server, measure_memory_usage, datastore_path):\n    \n    include_filters = \".changetext\"\n\n    set_original_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": include_filters,\n              'extract_text': '/\\d+ online/\\r\\n/\\d+ guests/\\r\\n/somecase insensitive \\d+/i\\r\\n/somecase insensitive (345\\d)/i\\r\\n/issue1828.+?2022/i',\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"\n              },\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    #issue 1828\n    assert b'not at the start of the expression' not in res.data\n\n    #  Make a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should have 'has-unread-changes' still\n    # Because it should be looking at only that 'sametext' id\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    # Check HTML conversion detected and workd\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b'1000 online' in res.data\n\n    # All regex matching should be here\n    assert b'2000 online' in res.data\n\n    # Both regexs should be here\n    assert b'80 guests' in res.data\n\n    # Regex with flag handling should be here\n    assert b'SomeCase insensitive 3456' in res.data\n\n    # Singular group from /somecase insensitive (345\\d)/i\n    assert b'3456' in res.data\n\n    # Regex with multiline flag handling should be here\n\n    # Should not be here\n    assert b'Some text that did change' not in res.data\n\n\n\ndef test_regex_error_handling(client, live_server, measure_memory_usage, datastore_path):\n\n    \n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    time.sleep(0.2)\n    ### test regex error handling\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\"extract_text\": '/something bad\\d{3/XYZ',\n              \"url\": test_url,\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b'is not a valid regular expression.' in res.data\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_filter_exist_changes.py",
    "content": "#!/usr/bin/env python3\n\n# https://www.reddit.com/r/selfhosted/comments/wa89kp/comment/ii3a4g7/?context=3\nimport os\nimport time\nfrom flask import url_for\nfrom .util import set_original_response, live_server_setup, wait_for_notification_endpoint_output\nfrom changedetectionio.model import App\n\n\ndef set_response_without_filter(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div id=\"nope-doesnt-exist\">Some text thats the same</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef set_response_with_filter(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div class=\"ticket-available\">Ticket now on sale!</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\ndef test_filter_doesnt_exist_then_exists_should_get_notification(client, live_server, measure_memory_usage, datastore_path):\n#  Filter knowingly doesn't exist, like someone setting up a known filter to see if some cinema tickets are on sale again\n#  And the page has that filter available\n#  Then I should get a notification\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    # Give the endpoint time to spin up\n    time.sleep(1)\n    set_response_without_filter(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'cinema'},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    # Give the thread time to pick up the first version\n    time.sleep(3)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    url = url_for('test_notification_endpoint', _external=True)\n    notification_url = url.replace('http', 'json')\n\n    print(\">>>> Notification URL: \" + notification_url)\n\n    # Just a regular notification setting, this will be used by the special 'filter not found' notification\n    notification_form_data = {\"notification_urls\": notification_url,\n                              \"notification_title\": \"New ChangeDetection.io Notification - {{watch_url}}\",\n                              \"notification_body\": \"BASE URL: {{base_url}}\\n\"\n                                                   \"Watch URL: {{watch_url}}\\n\"\n                                                   \"Watch UUID: {{watch_uuid}}\\n\"\n                                                   \"Watch title: {{watch_title}}\\n\"\n                                                   \"Watch tag: {{watch_tag}}\\n\"\n                                                   \"Preview: {{preview_url}}\\n\"\n                                                   \"Diff URL: {{diff_url}}\\n\"\n                                                   \"Snapshot: {{current_snapshot}}\\n\"\n                                                   \"Diff: {{diff}}\\n\"\n                                                   \"Diff Full: {{diff_full}}\\n\"\n                                                   \"Diff as Patch: {{diff_patch}}\\n\"\n                                                   \":-)\",\n                              \"notification_format\": 'text'}\n\n    notification_form_data.update({\n        \"url\": test_url,\n        \"tags\": \"my tag\",\n        \"title\": \"my title\",\n        \"headers\": \"\",\n        # preprended with extra filter that intentionally doesn't match any entry,\n        # notification should still be sent even if first filter does not match (PR#3516)\n        \"include_filters\": \".non-matching-selector\\n.ticket-available\",\n        \"fetch_backend\": \"html_requests\",\n        \"time_between_check_use_default\": \"y\"})\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data=notification_form_data,\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n\n    # Shouldn't exist, shouldn't have fired\n    assert not os.path.isfile(os.path.join(datastore_path, \"notification.txt\"))\n    # Now the filter should exist\n    set_response_with_filter(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n\n    assert os.path.isfile(os.path.join(datastore_path, \"notification.txt\"))\n\n    with open(os.path.join(datastore_path, \"notification.txt\"), 'r') as f:\n        notification = f.read()\n\n    assert 'Ticket now on sale' in notification\n    os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n"
  },
  {
    "path": "changedetectionio/tests/test_filter_failure_notification.py",
    "content": "import os\nimport time\nfrom flask import url_for\nfrom .util import set_original_response, wait_for_all_checks, wait_for_notification_endpoint_output, delete_all_watches\nfrom ..notification import valid_notification_formats\n\n\ndef set_response_with_filter(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div id=\"nope-doesnt-exist\">Some text thats the same</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\ndef run_filter_test(client, live_server, content_filter, app_notification_format, datastore_path):\n\n    # Response WITHOUT the filter ID element\n    set_original_response(datastore_path=datastore_path)\n    live_server.app.config['DATASTORE'].data['settings']['application']['notification_format'] = app_notification_format\n\n    # Goto the edit page, add our ignore text\n    notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'post')\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n\n    # cleanup for the next\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n    notification_file = os.path.join(datastore_path, \"notification.txt\")\n    if os.path.isfile(notification_file):\n        os.unlink(notification_file)\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b'No web page change detection watches configured' not in res.data\n\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, \"No filter = No filter failure\"\n\n    watch_data = {\"notification_urls\": notification_url,\n                  \"notification_title\": \"New ChangeDetection.io Notification - {{watch_url}}\",\n                  \"notification_body\": \"BASE URL: {{base_url}}\\n\"\n                                       \"Watch URL: {{watch_url}}\\n\"\n                                       \"Watch UUID: {{watch_uuid}}\\n\"\n                                       \"Watch title: {{watch_title}}\\n\"\n                                       \"Watch tag: {{watch_tag}}\\n\"\n                                       \"Preview: {{preview_url}}\\n\"\n                                       \"Diff URL: {{diff_url}}\\n\"\n                                       \"Snapshot: {{current_snapshot}}\\n\"\n                                       \"Diff: {{diff}}\\n\"\n                                       \"Diff Full: {{diff_full}}\\n\"\n                                       \"Diff as Patch: {{diff_patch}}\\n\"\n                                       \":-)\",\n                  \"notification_format\": 'text',\n                  \"fetch_backend\": \"html_requests\",\n                  \"filter_failure_notification_send\": 'y',\n                  \"time_between_check_use_default\": \"y\",\n                  \"headers\": \"\",\n                  \"tags\": \"my tag\",\n                  \"title\": \"my title 123\",\n                  \"time_between_check-hours\": 5,  # So that the queue runner doesnt also put it in\n                  \"url\": test_url,\n                  }\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data=watch_data,\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, \"No filter = No filter failure\"\n\n    # Now add a filter, because recheck hours == 5, ONLY pressing of the [edit] or [recheck all] should trigger\n    watch_data['include_filters'] = content_filter\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data=watch_data,\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # It should have checked once so far and given this error (because we hit SAVE)\n\n    wait_for_all_checks(client)\n    assert not os.path.isfile(notification_file)\n\n    # Hitting [save] would have triggered a recheck, and we have a filter, so this would be ONE failure\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 1, \"Should have been checked once\"\n\n    # recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented)\n    # Add 4 more checks\n    checked = 0\n    ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)\n    for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2):\n        checked += 1\n        client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        wait_for_all_checks(client)\n        res = client.get(url_for(\"watchlist.index\"))\n        assert b'Warning, no filters were found' in res.data\n        assert not os.path.isfile(notification_file)\n        time.sleep(2)\n        wait_for_all_checks(client)\n\n    wait_for_all_checks(client)\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 5\n\n    time.sleep(2)\n    # One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n\n    # Now it should exist and contain our \"filter not found\" alert\n    assert os.path.isfile(notification_file)\n    with open(notification_file, 'r') as f:\n        notification = f.read()\n\n    assert 'Your configured CSS/xPath filters' in notification\n\n\n    # Text (or HTML conversion) markup to make the notifications a little nicer should have worked\n    if app_notification_format.startswith('html'):\n        # apprise should have used sax-escape (&#39; instead of &quot;, \" etc), lets check it worked\n\n        from apprise.conversion import convert_between\n        from apprise.common import NotifyFormat\n        escaped_filter = convert_between(NotifyFormat.TEXT, NotifyFormat.HTML, content_filter)\n\n        assert escaped_filter in notification or escaped_filter.replace('&quot;', '&#34;') in notification\n        assert 'a href=\"' in notification # Quotes should still be there so the link works\n\n    else:\n        assert 'a href' not in notification\n        assert content_filter in notification\n\n    # Remove it and prove that it doesn't trigger when not expected\n    # It should register a change, but no 'filter not found'\n    os.unlink(notification_file)\n    set_response_with_filter(datastore_path)\n\n    # Try several times, it should NOT have 'filter not found'\n    for i in range(0, ATTEMPT_THRESHOLD_SETTING + 2):\n        client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        wait_for_all_checks(client)\n\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n    # It should have sent a notification, but..\n    assert os.path.isfile(notification_file)\n    # but it should not contain the info about a failed filter (because there was none in this case)\n    with open(notification_file, 'r') as f:\n        notification = f.read()\n    assert not 'CSS/xPath filter was not present in the page' in notification\n\n    # Re #1247 - All tokens got replaced correctly in the notification\n    assert uuid in notification\n\n    # cleanup for the next\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n    os.unlink(notification_file)\n    delete_all_watches(client)\n\n\ndef test_check_include_filters_failure_notification(client, live_server, measure_memory_usage, datastore_path):\n    #   #  live_server_setup(live_server) # Setup on conftest per function\n    run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('htmlcolor'), datastore_path=datastore_path)\n    # Check markup send conversion didnt affect plaintext preference\n    run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('text'), datastore_path=datastore_path)\n    delete_all_watches(client)\n\ndef test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage, datastore_path):\n    #   #  live_server_setup(live_server) # Setup on conftest per function\n    run_filter_test(client=client, live_server=live_server, content_filter='//*[@id=\"nope-doesnt-exist\"]', app_notification_format=valid_notification_formats.get('htmlcolor'), datastore_path=datastore_path)\n    delete_all_watches(client)\n\n# Test that notification is never sent\n\ndef test_basic_markup_from_text(client, live_server, measure_memory_usage, datastore_path):\n    # Test the notification error templates convert to HTML if needed (link activate)\n    from ..notification.handler import markup_text_links_to_html\n    x = markup_text_links_to_html(\"hello https://google.com\")\n    assert 'a href' in x\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_group.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, get_UUID_for_tag_name, extract_UUID_from_client, delete_all_watches\nimport os\n\nfrom ..store import ChangeDetectionStore\n\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p id=\"only-this\">Should be only this</p>\n     <br>\n     <p id=\"not-this\">And never this</p>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p id=\"only-this\">Should be REALLY only this</p>\n     <br>\n     <p id=\"not-this\">And never this</p>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\ndef test_setup_group_tag(client, live_server, measure_memory_usage, datastore_path):\n    \n    set_original_response(datastore_path=datastore_path)\n\n    # Add a tag with some config, import a tag and it should roughly work\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"test-tag\"},\n        follow_redirects=True\n    )\n    assert b\"Tag added\" in res.data\n    assert b\"test-tag\" in res.data\n\n    res = client.post(\n        url_for(\"tags.form_tag_edit_submit\", uuid=\"first\"),\n        data={\"name\": \"test-tag\",\n              \"include_filters\": '#only-this',\n              \"subtractive_selectors\": '#not-this'},\n        follow_redirects=True\n    )\n    assert b\"Updated\" in res.data\n    tag_uuid = get_UUID_for_tag_name(client, name=\"test-tag\")\n    res = client.get(\n        url_for(\"tags.form_tag_edit\", uuid=\"first\")\n    )\n    assert b\"#only-this\" in res.data\n    assert b\"#not-this\" in res.data\n\n    # Tag should be setup and ready, now add a watch\n\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": test_url + \"?first-imported=1 test-tag, extra-import-tag\"},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'import-tag' in res.data\n    assert b'extra-import-tag' in res.data\n\n    res = client.get(\n        url_for(\"tags.tags_overview_page\"),\n        follow_redirects=True\n    )\n    assert b'import-tag' in res.data\n    assert b'extra-import-tag' in res.data\n\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'Warning, no filters were found' not in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b'Should be only this' in res.data\n    assert b'And never this' not in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    # 2307 the UI notice should appear in the placeholder\n    assert b'WARNING: Watch has tag/groups set with special filters' in res.data\n\n    # RSS Group tag filter\n    # An extra one that should be excluded\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": test_url + \"?should-be-excluded=1 some-tag\"},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n    set_modified_response(datastore_path=datastore_path)\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    rss_token = extract_rss_token_from_UI(client)\n    res = client.get(\n        url_for(\"rss.feed\", token=rss_token, tag=\"extra-import-tag\", _external=True),\n        follow_redirects=True\n    )\n    assert b\"should-be-excluded\" not in res.data\n    assert res.status_code == 200\n    assert b\"first-imported=1\" in res.data\n    delete_all_watches(client)\n\ndef test_tag_import_singular(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": test_url + \" test-tag, test-tag\\r\\n\"+ test_url + \"?x=1 test-tag, test-tag\\r\\n\"},\n        follow_redirects=True\n    )\n    assert b\"2 Imported\" in res.data\n\n    res = client.get(\n        url_for(\"tags.tags_overview_page\"),\n        follow_redirects=True\n    )\n    # Should be only 1 tag because they both had the same\n    assert len(live_server.app.config['DATASTORE'].data['settings']['application'].get('tags')) ==1\n\n    delete_all_watches(client)\n\ndef test_tag_add_in_ui(client, live_server, measure_memory_usage, datastore_path):\n    \n#\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"new-test-tag\"},\n        follow_redirects=True\n    )\n    assert b\"Tag added\" in res.data\n    assert b\"new-test-tag\" in res.data\n\n    res = client.get(url_for(\"tags.delete_all\"), follow_redirects=True)\n    assert b'All tags deleted' in res.data\n\n    delete_all_watches(client)\n\ndef test_group_tag_notification(client, live_server, measure_memory_usage, datastore_path):\n    delete_all_watches(client)\n\n    set_original_response(datastore_path=datastore_path)\n\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'test-tag, other-tag'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')\n    notification_form_data = {\"notification_urls\": notification_url,\n                              \"notification_title\": \"New GROUP TAG ChangeDetection.io Notification - {{watch_url}}\",\n                              \"notification_body\": \"BASE URL: {{base_url}}\\n\"\n                                                   \"Watch URL: {{watch_url}}\\n\"\n                                                   \"Watch UUID: {{watch_uuid}}\\n\"\n                                                   \"Watch title: {{watch_title}}\\n\"\n                                                   \"Watch tag: {{watch_tag}}\\n\"\n                                                   \"Preview: {{preview_url}}\\n\"\n                                                   \"Diff URL: {{diff_url}}\\n\"\n                                                   \"Snapshot: {{current_snapshot}}\\n\"\n                                                   \"Diff: {{diff}}\\n\"\n                                                   \"Diff Added: {{diff_added}}\\n\"\n                                                   \"Diff Removed: {{diff_removed}}\\n\"\n                                                   \"Diff Full: {{diff_full}}\\n\"\n                                                   \"Diff as Patch: {{diff_patch}}\\n\"\n                                                   \":-)\",\n                              \"notification_screenshot\": True,\n                              \"notification_format\": 'text',\n                              \"title\": \"test-tag\"}\n\n    res = client.post(\n        url_for(\"tags.form_tag_edit_submit\", uuid=get_UUID_for_tag_name(client, name=\"test-tag\")),\n        data=notification_form_data,\n        follow_redirects=True\n    )\n    assert b\"Updated\" in res.data\n\n    wait_for_all_checks(client)\n\n    set_modified_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    time.sleep(3)\n\n    assert os.path.isfile(os.path.join(datastore_path, \"notification.txt\"))\n\n    # Verify what was sent as a notification, this file should exist\n    with open(os.path.join(datastore_path, \"notification.txt\"), \"r\") as f:\n        notification_submission = f.read()\n    os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n\n    # Did we see the URL that had a change, in the notification?\n    # Diff was correctly executed\n    assert test_url in notification_submission\n    assert ':-)' in notification_submission\n    assert \"Diff Full: Some initial text\" in notification_submission\n    assert \"New GROUP TAG ChangeDetection.io\" in notification_submission\n    assert \"test-tag\" in notification_submission\n    assert \"other-tag\" in notification_submission\n\n    #@todo Test that multiple notifications fired\n    #@todo Test that each of multiple notifications with different settings\n    delete_all_watches(client)\n\ndef test_limit_tag_ui(client, live_server, measure_memory_usage, datastore_path):\n\n    test_url = url_for('test_random_content_endpoint', _external=True)\n\n    # A space can label the tag, only the first one will have a tag\n    client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": f\"{test_url} test-tag\\r\\n{test_url}\"},\n        follow_redirects=True\n    )\n    tag_uuid = get_UUID_for_tag_name(client, name=\"test-tag\")\n    assert tag_uuid\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'test-tag' in res.data\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Should be both unviewed\n    res = client.get(url_for(\"watchlist.index\"))\n    assert res.data.count(b' unviewed ') == 2\n\n\n    # Now we recheck only the tag\n    client.get(url_for('ui.mark_all_viewed', tag=tag_uuid), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Should be only 1 unviewed\n    res = client.get(url_for(\"watchlist.index\"))\n    assert res.data.count(b' unviewed ') == 1\n\n\n    delete_all_watches(client)\n    res = client.get(url_for(\"tags.delete_all\"), follow_redirects=True)\n    assert b'All tags deleted' in res.data\n\ndef test_clone_tag_on_import(client, live_server, measure_memory_usage, datastore_path):\n    \n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": test_url + \" test-tag, another-tag\\r\\n\"},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'test-tag' in res.data\n    assert b'another-tag' in res.data\n\n    watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    res = client.get(url_for(\"ui.form_clone\", uuid=watch_uuid), follow_redirects=True)\n\n    assert b'Cloned' in res.data\n    res = client.get(url_for(\"watchlist.index\"))\n    # 2 times plus the top link to tag\n    assert res.data.count(b'test-tag') == 3\n    assert res.data.count(b'another-tag') == 3\n    delete_all_watches(client)\n\ndef test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    test_url = url_for('test_endpoint', _external=True)\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": ' test-tag, another-tag      '},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'test-tag' in res.data\n    assert b'another-tag' in res.data\n\n    watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    res = client.get(url_for(\"ui.form_clone\", uuid=watch_uuid), follow_redirects=True)\n    assert b'Cloned' in res.data\n\n    res = client.get(url_for(\"watchlist.index\"))\n    # 2 times plus the top link to tag\n    assert res.data.count(b'test-tag') == 3\n    assert res.data.count(b'another-tag') == 3\n    delete_all_watches(client)\n\n    res = client.get(url_for(\"tags.delete_all\"), follow_redirects=True)\n    assert b'All tags deleted' in res.data\n\ndef test_order_of_filters_tag_filter_and_watch_filter(client, live_server, measure_memory_usage, datastore_path):\n\n    # Add a tag with some config, import a tag and it should roughly work\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"test-tag-keep-order\"},\n        follow_redirects=True\n    )\n    assert b\"Tag added\" in res.data\n    assert b\"test-tag-keep-order\" in res.data\n    tag_filters = [\n            '#only-this', # duplicated filters\n            '#only-this',\n            '#only-this',\n            '#only-this',\n            ]\n\n    res = client.post(\n        url_for(\"tags.form_tag_edit_submit\", uuid=\"first\"),\n        data={\"name\": \"test-tag-keep-order\",\n              \"include_filters\": '\\n'.join(tag_filters) },\n        follow_redirects=True\n    )\n    assert b\"Updated\" in res.data\n    tag_uuid = get_UUID_for_tag_name(client, name=\"test-tag-keep-order\")\n    res = client.get(\n        url_for(\"tags.form_tag_edit\", uuid=\"first\")\n    )\n    assert b\"#only-this\" in res.data\n\n\n    d = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p id=\"only-this\">And 1 this</p>\n     <br>\n     <p id=\"not-this\">And 2 this</p>\n     <p id=\"\">And 3 this</p><!--/html/body/p[3]/-->\n     <p id=\"\">And 4 this</p><!--/html/body/p[4]/-->\n     <p id=\"\">And 5 this</p><!--/html/body/p[5]/-->\n     <p id=\"\">And 6 this</p><!--/html/body/p[6]/-->\n     <p id=\"\">And 7 this</p><!--/html/body/p[7]/-->\n     <p id=\"\">And 8 this</p><!--/html/body/p[8]/-->\n     <p id=\"\">And 9 this</p><!--/html/body/p[9]/-->\n     <p id=\"\">And 10 this</p><!--/html/body/p[10]/-->\n     <p id=\"\">And 11 this</p><!--/html/body/p[11]/-->\n     <p id=\"\">And 12 this</p><!--/html/body/p[12]/-->\n     <p id=\"\">And 13 this</p><!--/html/body/p[13]/-->\n     <p id=\"\">And 14 this</p><!--/html/body/p[14]/-->\n     <p id=\"not-this\">And 15 this</p><!--/html/body/p[15]/-->\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(d)\n\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    filters = [\n            '/html/body/p[3]',\n            '/html/body/p[4]',\n            '/html/body/p[5]',\n            '/html/body/p[6]',\n            '/html/body/p[7]',\n            '/html/body/p[8]',\n            '/html/body/p[9]',\n            '/html/body/p[10]',\n            '/html/body/p[11]',\n            '/html/body/p[12]',\n            '/html/body/p[13]', # duplicated tags\n            '/html/body/p[13]',\n            '/html/body/p[13]',\n            '/html/body/p[13]',\n            '/html/body/p[13]',\n            '/html/body/p[14]',\n            ]\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": '\\n'.join(filters),\n            \"url\": test_url,\n            \"tags\": \"test-tag-keep-order\",\n            \"headers\": \"\",\n            'fetch_backend': \"html_requests\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"And 1 this\" in res.data  # test-tag-keep-order\n\n    a_tag_filter_check = b'And 1 this' #'#only-this' of tag_filters\n    # check there is no duplication of tag_filters\n    assert res.data.count(a_tag_filter_check) == 1, f\"duplicated filters didn't removed {res.data.count(a_tag_filter_check)} of {a_tag_filter_check} in {res.data=}\"\n\n    a_filter_check = b\"And 13 this\" # '/html/body/p[13]'\n    # check there is no duplication of filters\n    assert res.data.count(a_filter_check) == 1, f\"duplicated filters didn't removed. {res.data.count(a_filter_check)} of {a_filter_check} in {res.data=}\"\n\n    a_filter_check_not_include = b\"And 2 this\" # '/html/body/p[2]'\n    assert a_filter_check_not_include not in res.data\n\n    checklist = [\n            b\"And 3 this\",\n            b\"And 4 this\",\n            b\"And 5 this\",\n            b\"And 6 this\",\n            b\"And 7 this\",\n            b\"And 8 this\",\n            b\"And 9 this\",\n            b\"And 10 this\",\n            b\"And 11 this\",\n            b\"And 12 this\",\n            b\"And 13 this\",\n            b\"And 14 this\",\n            b\"And 1 this\", # result of filter from tag.\n            ]\n    # check whether everything a user requested is there\n    for test in checklist:\n        assert test in res.data\n\n    # check whether everything a user requested is in order of filters.\n    n = 0\n    for test in checklist:\n        t_index = res.data[n:].find(test)\n        # if the text is not searched, return -1.\n        assert t_index >= 0, f\"\"\"failed because {test=} not in {res.data[n:]=}\n#####################\nLooks like some feature changed the order of result of filters.\n#####################\nthe {test} appeared before. {test in res.data[:n]=}\n{res.data[:n]=}\n        \"\"\"\n        n += t_index + len(test)\n\n    delete_all_watches(client)\n\n\ndef test_tag_json_persistence(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that tags are saved to individual tag.json files and loaded correctly.\n\n    This test verifies the update_27 tag storage refactoring:\n    - Tags are saved to {uuid}/tag.json files\n    - Tags persist across datastore restarts\n    - Tag edits write to tag.json\n    - Tag deletion removes tag.json file\n    \"\"\"\n    import json\n\n    datastore = client.application.config.get('DATASTORE')\n\n    # 1. Create a tag\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"persistence-test-tag\"},\n        follow_redirects=True\n    )\n    assert b\"Tag added\" in res.data\n\n    tag_uuid = get_UUID_for_tag_name(client, name=\"persistence-test-tag\")\n    assert tag_uuid, \"Tag UUID should exist\"\n\n    # 2. Verify tag.json file was created\n    tag_json_path = os.path.join(datastore_path, tag_uuid, \"tag.json\")\n    assert os.path.exists(tag_json_path), f\"tag.json should exist at {tag_json_path}\"\n\n    # 3. Verify tag.json contains correct data\n    with open(tag_json_path, 'r') as f:\n        tag_data = json.load(f)\n    assert tag_data['title'] == 'persistence-test-tag'\n    assert tag_data['uuid'] == tag_uuid\n    assert 'date_created' in tag_data\n\n    # 4. Edit the tag\n    res = client.post(\n        url_for(\"tags.form_tag_edit_submit\", uuid=tag_uuid),\n        data={\n            \"name\": \"persistence-test-tag\",\n            \"notification_muted\": True,\n            \"include_filters\": '#test-filter'\n        },\n        follow_redirects=True\n    )\n    assert b\"Updated\" in res.data\n\n    # 5. Verify tag.json was updated\n    with open(tag_json_path, 'r') as f:\n        tag_data = json.load(f)\n    assert tag_data['notification_muted'] == True\n    assert '#test-filter' in tag_data.get('include_filters', [])\n\n    # 5a. Verify tag is NOT in changedetection.json (tags should be in tag.json only)\n    changedetection_json_path = os.path.join(datastore_path, \"changedetection.json\")\n    with open(changedetection_json_path, 'r') as f:\n        settings_data = json.load(f)\n    # Tags dict should be empty in settings (all tags are in individual files)\n    assert settings_data['settings']['application']['tags'] == {}, \\\n        \"Tags should NOT be saved to changedetection.json (should be empty dict)\"\n\n    # 6. Simulate restart - reload datastore\n    datastore2 = ChangeDetectionStore(datastore_path=datastore_path, include_default_watches=False, version_tag='test')\n\n    # 7. Verify tag was loaded from tag.json\n    assert tag_uuid in datastore2.data['settings']['application']['tags']\n    loaded_tag = datastore2.data['settings']['application']['tags'][tag_uuid]\n    assert loaded_tag['title'] == 'persistence-test-tag'\n    assert loaded_tag['notification_muted'] == True\n    assert '#test-filter' in loaded_tag.get('include_filters', [])\n\n    # 8. Delete the tag via API\n    res = client.get(url_for(\"tags.delete\", uuid=tag_uuid), follow_redirects=True)\n    assert b\"Tag deleted\" in res.data\n\n    # 9. Verify tag.json file was deleted\n    assert not os.path.exists(tag_json_path), f\"tag.json should be deleted at {tag_json_path}\"\n\n    # 10. Verify tag is removed from settings\n    assert tag_uuid not in datastore.data['settings']['application']['tags']\n\n    delete_all_watches(client)\n\n\ndef test_tag_json_migration_update_27(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that update_27 migration correctly moves tags to individual files.\n\n    This simulates a pre-update_27 datastore and verifies migration works.\n    \"\"\"\n    import json\n\n    # 1. Create multiple tags\n    tag_names = ['migration-tag-1', 'migration-tag-2', 'migration-tag-3']\n    tag_uuids = []\n\n    for tag_name in tag_names:\n        res = client.post(\n            url_for(\"tags.form_tag_add\"),\n            data={\"name\": tag_name},\n            follow_redirects=True\n        )\n        assert b\"Tag added\" in res.data\n        tag_uuid = get_UUID_for_tag_name(client, name=tag_name)\n        tag_uuids.append(tag_uuid)\n\n    # 2. Verify all tag.json files exist (update_27 already ran during add_tag)\n    for tag_uuid in tag_uuids:\n        tag_json_path = os.path.join(datastore_path, tag_uuid, \"tag.json\")\n        assert os.path.exists(tag_json_path), f\"tag.json should exist for {tag_uuid}\"\n\n    # 2a. Verify tags are NOT in changedetection.json\n    changedetection_json_path = os.path.join(datastore_path, \"changedetection.json\")\n    with open(changedetection_json_path, 'r') as f:\n        settings_data = json.load(f)\n    assert settings_data['settings']['application']['tags'] == {}, \\\n        \"Tags should NOT be in changedetection.json after migration\"\n\n    # 3. Simulate restart\n    datastore2 = ChangeDetectionStore(datastore_path=datastore_path, include_default_watches=False, version_tag='test')\n\n    # 4. Verify all tags loaded from tag.json files\n    for idx, tag_uuid in enumerate(tag_uuids):\n        assert tag_uuid in datastore2.data['settings']['application']['tags']\n        loaded_tag = datastore2.data['settings']['application']['tags'][tag_uuid]\n        assert loaded_tag['title'] == tag_names[idx]\n\n    # Cleanup\n    res = client.get(url_for(\"tags.delete_all\"), follow_redirects=True)\n    assert b'All tags deleted' in res.data\n\n    # Verify all tag.json files were deleted\n    for tag_uuid in tag_uuids:\n        tag_json_path = os.path.join(datastore_path, tag_uuid, \"tag.json\")\n        assert not os.path.exists(tag_json_path), f\"tag.json should be deleted for {tag_uuid}\"\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_history_consistency.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nimport os\nimport json\nfrom flask import url_for\nfrom loguru import logger\nfrom .. import strtobool\nfrom .util import wait_for_all_checks, delete_all_watches\nimport brotli\n\n\ndef test_consistent_history(client, live_server, measure_memory_usage, datastore_path):\n\n    uuids = set()\n    sys_fetch_workers = int(os.getenv(\"FETCH_WORKERS\", 10))\n    workers = range(1, sys_fetch_workers)\n    now = time.time()\n\n    for one in workers:\n        if strtobool(os.getenv(\"TEST_WITH_BROTLI\")):\n            # A very long string that WILL trigger Brotli compression of the snapshot\n            # BROTLI_COMPRESS_SIZE_THRESHOLD should be set to say 200\n            from ..model.Watch import BROTLI_COMPRESS_SIZE_THRESHOLD\n            content = str(one) + \"x\" + str(one) * (BROTLI_COMPRESS_SIZE_THRESHOLD + 10)\n        else:\n            # Just enough to test datastore\n            content = str(one)+'x'\n\n        test_url = url_for('test_endpoint', content_type=\"text/html\", content=content, _external=True)\n        uuids.add(client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'title': str(one)}))\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n    duration = time.time() - now\n    per_worker = duration/sys_fetch_workers\n    if sys_fetch_workers < 20:\n        per_worker_threshold=0.6\n    elif sys_fetch_workers < 50:\n        per_worker_threshold = 0.8\n    else:\n        per_worker_threshold = 1.5\n\n    logger.debug(f\"All fetched in {duration:.2f}s, {per_worker}s per worker\")\n    # Problematic on github\n    #assert per_worker < per_worker_threshold, f\"If concurrency is working good, no blocking async problems, each worker ({sys_fetch_workers} workers) should have done his job in under {per_worker_threshold}s, got {per_worker:.2f}s per worker, total duration was {duration:.2f}s\"\n\n    # Essentially just triggers the DB write/update\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-empty_pages_are_a_change\": \"\",\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Wait for the sync DB save to happen\n    time.sleep(2)\n\n    # Check which format is being used\n    datastore_path = live_server.app.config['DATASTORE'].datastore_path\n    changedetection_json = os.path.join(datastore_path, 'changedetection.json')\n    url_watches_json = os.path.join(datastore_path, 'url-watches.json')\n\n    json_obj = {'watching': {}}\n\n    if os.path.exists(changedetection_json):\n        # New format: individual watch.json files\n        logger.info(\"Testing with new format (changedetection.json + individual watch.json)\")\n\n        # Load each watch.json file\n        for uuid in live_server.app.config['DATASTORE'].data['watching'].keys():\n            watch_json_file = os.path.join(datastore_path, uuid, 'watch.json')\n            assert os.path.isfile(watch_json_file), f\"watch.json should exist at {watch_json_file}\"\n\n            with open(watch_json_file, 'r', encoding='utf-8') as f:\n                json_obj['watching'][uuid] = json.load(f)\n    else:\n        # Legacy format: url-watches.json\n        logger.info(\"Testing with legacy format (url-watches.json)\")\n        with open(url_watches_json, 'r', encoding='utf-8') as f:\n            json_obj = json.load(f)\n\n    # assert the right amount of watches was found in the JSON\n    assert len(json_obj['watching']) == len(workers), \"Correct number of watches was found in the JSON\"\n\n    i = 0\n    # each one should have a history.txt containing just one line\n    for w in json_obj['watching'].keys():\n        i += 1\n        history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt')\n        assert os.path.isfile(history_txt_index_file), f\"History.txt should exist where I expect it at {history_txt_index_file}\"\n\n        # Should be no errors (could be from brotli etc)\n        assert not live_server.app.config['DATASTORE'].data['watching'][w].get('last_error')\n\n        # Same like in model.Watch\n        with open(history_txt_index_file, \"r\") as f:\n            tmp_history = dict(i.strip().split(',', 2) for i in f.readlines())\n            assert len(tmp_history) == 1, \"History.txt should contain 1 line\"\n\n        # Should be two files,. the history.txt , and the snapshot.txt\n        files_in_watch_dir = os.listdir(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w))\n\n        # Find the snapshot one\n        for fname in files_in_watch_dir:\n            if fname != 'history.txt' and fname != 'watch.json' and fname != 'last-checksum.txt' and 'html' not in fname:\n                if strtobool(os.getenv(\"TEST_WITH_BROTLI\")):\n                    assert fname.endswith('.br'), \"Forced TEST_WITH_BROTLI then it should be a .br filename\"\n\n                full_snapshot_history_path = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname)\n                # contents should match what we requested as content returned from the test url\n                if fname.endswith('.br'):\n                    with open(full_snapshot_history_path, 'rb') as f:\n                        contents = brotli.decompress(f.read()).decode('utf-8')\n                else:\n                    with open(full_snapshot_history_path, 'r') as snapshot_f:\n                        contents = snapshot_f.read()\n\n                watch_title = json_obj['watching'][w]['title']\n                assert json_obj['watching'][w]['title'], \"Watch should have a title set\"\n                assert contents.startswith(watch_title + \"x\"), f\"Snapshot contents in file {fname} should start with '{watch_title}x', got '{contents}'\"\n\n        # With new format, we have watch.json, so 4 files minimum\n        # Note: last-checksum.txt may or may not exist - it gets cleared by settings changes,\n        # and this test changes settings before checking files\n        # This assertion should be AFTER the loop, not inside it\n        if os.path.exists(changedetection_json):\n            # 4 required files: watch.json, html.br, history.txt, extracted text snapshot\n            # last-checksum.txt is optional (cleared by settings changes in this test)\n            assert len(files_in_watch_dir) >= 4 and len(files_in_watch_dir) <= 5, f\"Should be 4-5 files in the dir with new format (last-checksum.txt is optional). Found {len(files_in_watch_dir)}: {files_in_watch_dir}\"\n        else:\n            # 3 required files: html.br, history.txt, extracted text snapshot\n            # last-checksum.txt is optional\n            assert len(files_in_watch_dir) >= 3 and len(files_in_watch_dir) <= 4, f\"Should be 3-4 files in the dir with legacy format (last-checksum.txt is optional). Found {len(files_in_watch_dir)}: {files_in_watch_dir}\"\n\n    # Check that 'default' Watch vars aren't accidentally being saved\n    if os.path.exists(changedetection_json):\n        # New format: check all individual watch.json files\n        for uuid in json_obj['watching'].keys():\n            watch_json_file = os.path.join(datastore_path, uuid, 'watch.json')\n            with open(watch_json_file, 'r', encoding='utf-8') as f:\n                assert '\"default\"' not in f.read(), f\"'default' probably shouldnt be here in {watch_json_file}, it came from when the 'default' Watch vars were accidently being saved\"\n    else:\n        # Legacy format: check url-watches.json\n        with open(url_watches_json, 'r', encoding='utf-8') as f:\n            assert '\"default\"' not in f.read(), \"'default' probably shouldnt be here, it came from when the 'default' Watch vars were accidently being saved\"\n\n\n    delete_all_watches(client)\n\ndef test_check_text_history_view(client, live_server, measure_memory_usage, datastore_path):\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html>test-one</html>\")\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # Set second version, Make a change\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html>test-two</html>\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=uuid))\n    assert b'test-one' in res.data\n    assert b'test-two' in res.data\n\n    # Set third version, Make a change\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html>test-three</html>\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # It should remember the last viewed time, so the first difference is not shown\n    res = client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n    assert b'test-three' in res.data\n    assert b'test-two' in res.data\n    assert b'test-one' not in res.data\n\n    delete_all_watches(client)\n\n\ndef test_history_trim_global_only(client, live_server, measure_memory_usage, datastore_path):\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = None\n    limit = 3\n\n    for i in range(0, 10):\n        with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n            f.write(f\"<html>test {i}</html>\")\n        if not uuid:\n            uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n        client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        wait_for_all_checks(client)\n\n        if i ==8:\n            watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n            history_n = len(list(watch.history.keys()))\n            logger.debug(f\"History length should be at limit {limit} and it is {history_n}\")\n            assert history_n == limit\n\n        if i == 6:\n            res = client.post(\n                url_for(\"settings.settings_page\"),\n                data={\"application-history_snapshot_max_length\": limit},\n                follow_redirects=True\n            )\n            # It will need to detect one more change to start trimming it, which is really at 'start of 7'\n            assert b'Settings updated' in res.data\n\n    delete_all_watches(client)\n\n\ndef test_history_trim_global_override_in_watch(client, live_server, measure_memory_usage, datastore_path):\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = None\n    limit = 3\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-history_snapshot_max_length\": 10000},\n        follow_redirects=True\n    )\n    # It will need to detect one more change to start trimming it, which is really at 'start of 7'\n    assert b'Settings updated' in res.data\n\n\n    for i in range(0, 10):\n        with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n            f.write(f\"<html>test {i}</html>\")\n        if not uuid:\n            uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n            res = client.post(\n                url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n                data={\"include_filters\": \"\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\",\n                      \"time_between_check_use_default\": \"y\", \"history_snapshot_max_length\": str(limit)},\n                follow_redirects=True\n            )\n            assert b\"Updated watch.\" in res.data\n\n            wait_for_all_checks(client)\n\n        client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        wait_for_all_checks(client)\n\n        if i == 8:\n            watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n            history_n = len(list(watch.history.keys()))\n            logger.debug(f\"History length should be at limit {limit} and it is {history_n}\")\n            assert history_n == limit\n\n        if i == 6:\n            res = client.post(\n                url_for(\"settings.settings_page\"),\n                data={\"application-history_snapshot_max_length\": limit},\n                follow_redirects=True\n            )\n            # It will need to detect one more change to start trimming it, which is really at 'start of 7'\n            assert b'Settings updated' in res.data\n\n    delete_all_watches(client)\n\n"
  },
  {
    "path": "changedetectionio/tests/test_html_to_text.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Test suite for the method to extract text from an html string\"\"\"\nfrom ..html_tools import html_to_text\n\n\ndef test_html_to_text_func():\n    test_html = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <a href=\"/first_link\"> More Text </a>\n     <br>\n     So let's see what happens.  <br>\n     <a href=\"second_link.com\"> Even More Text </a>\n     </body>\n     </html>\n    \"\"\"\n\n    # extract text, with 'render_anchor_tag_content' set to False\n    text_content = html_to_text(test_html, render_anchor_tag_content=False)\n\n    no_links_text = \\\n        \"Some initial text\\n\\nWhich is across multiple \" \\\n        \"lines\\n\\nMore Text\\nSo let's see what happens.\\nEven More Text\"\n\n    # check that no links are in the extracted text\n    assert text_content == no_links_text\n\n    # extract text, with 'render_anchor_tag_content' set to True\n    text_content = html_to_text(test_html, render_anchor_tag_content=True)\n\n    links_text = \\\n        \"Some initial text\\n\\nWhich is across multiple lines\\n\\n[ More Text \" \\\n        \"](/first_link)\\nSo let's see what happens.\\n[ Even More Text ]\" \\\n        \"(second_link.com)\"\n\n    # check that links are present in the extracted text\n    assert text_content == links_text\n"
  },
  {
    "path": "changedetectionio/tests/test_i18n.py",
    "content": "#!/usr/bin/env python3\n\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\n\n\ndef test_zh_TW(client, live_server, measure_memory_usage, datastore_path):\n    import time\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Be sure we got a session cookie\n    res = client.get(url_for(\"watchlist.index\"), follow_redirects=True)\n\n    res = client.get(\n        url_for(\"set_language\", locale=\"zh_Hant_TW\"), # Traditional\n        follow_redirects=True\n    )\n    # HTML follows BCP 47 language tag rules, not underscore-based locale formats.\n    assert b'<html lang=\"zh-Hant-TW\"' in res.data\n    assert b'Cannot set language without session cookie' not in res.data\n    assert '選擇語言'.encode() in res.data\n\n    # Check second set works\n    res = client.get(\n        url_for(\"set_language\", locale=\"en_GB\"),\n        follow_redirects=True\n    )\n    assert b'Cannot set language without session cookie' not in res.data\n    res = client.get(url_for(\"watchlist.index\"), follow_redirects=True)\n    assert b\"Select Language\" in res.data, \"Second set of language worked\"\n\n    # Check arbitration between zh_Hant_TW<->zh\n    res = client.get(\n        url_for(\"set_language\", locale=\"zh\"), # Simplified chinese\n        follow_redirects=True\n    )\n    res = client.get(url_for(\"watchlist.index\"), follow_redirects=True)\n    assert \"选择语言\".encode() in res.data, \"Simplified chinese worked and it means the flask-babel cache worked\"\n\n\n# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.\n# The Python timeago library (https://github.com/hustcc/timeago) supports 48 locales but uses different naming conventions than Flask-Babel.\ndef test_zh_Hant_TW_timeago_integration():\n    \"\"\"Test that zh_Hant_TW mapping works and timeago renders Traditional Chinese correctly\"\"\"\n    import timeago\n    from datetime import datetime, timedelta\n    from changedetectionio.languages import get_timeago_locale\n\n    # 1. Test the mapping\n    mapped_locale = get_timeago_locale('zh_Hant_TW')\n    assert mapped_locale == 'zh_TW', \"zh_Hant_TW should map to timeago's zh_TW\"\n    assert get_timeago_locale('zh_TW') == 'zh_TW', \"zh_TW should also map to zh_TW\"\n\n    # 2. Test timeago library renders Traditional Chinese with the mapped locale\n    now = datetime.now()\n\n    # Test various time periods with Traditional Chinese strings\n    result_15s = timeago.format(now - timedelta(seconds=15), now, mapped_locale)\n    assert '秒前' in result_15s, f\"Expected '秒前' in '{result_15s}'\"\n\n    result_5m = timeago.format(now - timedelta(minutes=5), now, mapped_locale)\n    assert '分鐘前' in result_5m, f\"Expected '分鐘前' in '{result_5m}'\"\n\n    result_2h = timeago.format(now - timedelta(hours=2), now, mapped_locale)\n    assert '小時前' in result_2h, f\"Expected '小時前' in '{result_2h}'\"\n\n    result_3d = timeago.format(now - timedelta(days=3), now, mapped_locale)\n    assert '天前' in result_3d, f\"Expected '天前' in '{result_3d}'\"\n\n\ndef test_language_switching(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that the language switching functionality works correctly.\n\n    1. Switch to Italian using the /set-language endpoint\n    2. Verify that Italian text appears on the page\n    3. Switch back to English and verify English text appears\n    \"\"\"\n\n    # Establish session cookie\n    client.get(url_for(\"watchlist.index\"), follow_redirects=True)\n\n    # Step 1: Set the language to Italian using the /set-language endpoint\n    res = client.get(\n        url_for(\"set_language\", locale=\"it\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Step 2: Request the index page - should be in Italian\n    # The session cookie should be maintained by the test client\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Check for Italian text - \"Annulla\" (translation of \"Cancel\")\n    assert b\"Annulla\" in res.data, \"Expected Italian text 'Annulla' not found after setting language to Italian\"\n\n    assert b'Modifiche testo/HTML, JSON e PDF' in res.data, \"Expected italian from processors.available_processors()\"\n\n    # Step 3: Switch back to English\n    res = client.get(\n        url_for(\"set_language\", locale=\"en\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Request the index page - should now be in English\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Check for English text\n    assert b\"Cancel\" in res.data, \"Expected English text 'Cancel' not found after switching back to English\"\n\n\ndef test_invalid_locale(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that setting an invalid locale doesn't break the application.\n    The app should ignore invalid locales and continue working.\n    \"\"\"\n\n    # Establish session cookie\n    client.get(url_for(\"watchlist.index\"), follow_redirects=True)\n\n    # First set to English\n    res = client.get(\n        url_for(\"set_language\", locale=\"en\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Try to set an invalid locale\n    res = client.get(\n        url_for(\"set_language\", locale=\"invalid_locale_xyz\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should still be able to access the page (should stay in English since invalid locale was ignored)\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n    assert b\"Cancel\" in res.data, \"Should remain in English when invalid locale is provided\"\n\n\ndef test_language_persistence_in_session(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that the language preference persists across multiple requests\n    within the same session, and that auto-detect properly clears the preference.\n    \"\"\"\n\n    # Establish session cookie\n    client.get(url_for(\"watchlist.index\"), follow_redirects=True)\n\n    # Set language to Italian\n    res = client.get(\n        url_for(\"set_language\", locale=\"it\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Make multiple requests - language should persist\n    for _ in range(3):\n        res = client.get(\n            url_for(\"watchlist.index\"),\n            follow_redirects=True\n        )\n\n        assert res.status_code == 200\n        assert b\"Annulla\" in res.data, \"Italian text should persist across requests\"\n\n    # Verify locale is in session\n    with client.session_transaction() as sess:\n        assert sess.get('locale') == 'it', \"Locale should be set in session\"\n\n    # Call auto-detect to clear the locale\n    res = client.get(\n        url_for(\"ui.delete_locale_language_session_var_if_it_exists\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n    # Verify the flash message appears (in English since we cleared the locale)\n    assert b\"Language set to auto-detect from browser\" in res.data, \"Should show flash message\"\n\n    # Verify locale was removed from session\n    with client.session_transaction() as sess:\n        assert 'locale' not in sess, \"Locale should be removed from session after auto-detect\"\n\n    # Now requests should use browser default (English in test environment)\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n    assert b\"Cancel\" in res.data, \"Should show English after auto-detect clears Italian\"\n    assert b\"Annulla\" not in res.data, \"Should not show Italian after auto-detect\"\n\n\ndef test_set_language_with_redirect(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that changing language keeps the user on the same page.\n    Example: User is on /settings, changes language, stays on /settings.\n    \"\"\"\n    from flask import url_for\n\n    # Establish session cookie\n    client.get(url_for(\"watchlist.index\"), follow_redirects=True)\n\n    # Set language with a redirect parameter (simulating language change from /settings)\n    res = client.get(\n        url_for(\"set_language\", locale=\"de\", redirect=\"/settings\"),\n        follow_redirects=False\n    )\n\n    # Should redirect back to settings\n    assert res.status_code in [302, 303]\n    assert '/settings' in res.location\n\n    # Verify language was set in session\n    with client.session_transaction() as sess:\n        assert sess.get('locale') == 'de'\n\n    # Test with invalid locale (should still redirect safely)\n    res = client.get(\n        url_for(\"set_language\", locale=\"invalid_locale\", redirect=\"/settings\"),\n        follow_redirects=False\n    )\n    assert res.status_code in [302, 303]\n    assert '/settings' in res.location\n\n    # Test with malicious redirect (should default to watchlist)\n    res = client.get(\n        url_for(\"set_language\", locale=\"en\", redirect=\"https://evil.com\"),\n        follow_redirects=False\n    )\n    assert res.status_code in [302, 303]\n    # Should not redirect to evil.com\n    assert 'evil.com' not in res.location\n\n\ndef test_time_unit_translations(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that time unit labels (Hours, Minutes, Seconds) and Chrome Extension\n    are correctly translated on the settings page for all supported languages.\n    \"\"\"\n    from flask import url_for\n\n    # Establish session cookie\n    client.get(url_for(\"watchlist.index\"), follow_redirects=True)\n\n    # Test Italian translations\n    res = client.get(url_for(\"set_language\", locale=\"it\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    res = client.get(url_for(\"settings.settings_page\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    # Check that Italian translations are present (not English)\n    assert b\"Minutes\" not in res.data or b\"Minuti\" in res.data, \"Expected Italian 'Minuti' not English 'Minutes'\"\n    assert b\"Ore\" in res.data, \"Expected Italian 'Ore' for Hours\"\n    assert b\"Minuti\" in res.data, \"Expected Italian 'Minuti' for Minutes\"\n    assert b\"Secondi\" in res.data, \"Expected Italian 'Secondi' for Seconds\"\n    assert b\"Estensione Chrome\" in res.data, \"Expected Italian 'Estensione Chrome' for Chrome Extension\"\n    assert b\"Intervallo tra controlli\" in res.data, \"Expected Italian 'Intervallo tra controlli' for Time Between Check\"\n    assert b\"Time Between Check\" not in res.data, \"Should not have English 'Time Between Check'\"\n\n    # Test Korean translations\n    res = client.get(url_for(\"set_language\", locale=\"ko\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    res = client.get(url_for(\"settings.settings_page\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    # Check that Korean translations are present (not English)\n    # Korean: Hours=시간, Minutes=분, Seconds=초, Chrome Extension=Chrome 확장 프로그램, Time Between Check=확인 간격\n    assert \"시간\".encode() in res.data, \"Expected Korean '시간' for Hours\"\n    assert \"분\".encode() in res.data, \"Expected Korean '분' for Minutes\"\n    assert \"초\".encode() in res.data, \"Expected Korean '초' for Seconds\"\n    assert \"Chrome 확장 프로그램\".encode() in res.data, \"Expected Korean 'Chrome 확장 프로그램' for Chrome Extension\"\n    assert \"확인 간격\".encode() in res.data, \"Expected Korean '확인 간격' for Time Between Check\"\n    # Make sure we don't have the incorrect translations\n    assert \"목요일\".encode() not in res.data, \"Should not have '목요일' (Thursday) for Hours\"\n    assert \"무음\".encode() not in res.data, \"Should not have '무음' (Mute) for Minutes\"\n    assert \"Chrome 요청\".encode() not in res.data, \"Should not have 'Chrome 요청' (Chrome requests) for Chrome Extension\"\n    assert b\"Time Between Check\" not in res.data, \"Should not have English 'Time Between Check'\"\n\n    # Test Chinese Simplified translations\n    res = client.get(url_for(\"set_language\", locale=\"zh\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    res = client.get(url_for(\"settings.settings_page\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    # Check that Chinese translations are present\n    # Chinese: Hours=小时, Minutes=分钟, Seconds=秒, Chrome Extension=Chrome 扩展程序, Time Between Check=检查间隔\n    assert \"小时\".encode() in res.data, \"Expected Chinese '小时' for Hours\"\n    assert \"分钟\".encode() in res.data, \"Expected Chinese '分钟' for Minutes\"\n    assert \"秒\".encode() in res.data, \"Expected Chinese '秒' for Seconds\"\n    assert \"Chrome 扩展程序\".encode() in res.data, \"Expected Chinese 'Chrome 扩展程序' for Chrome Extension\"\n    assert \"检查间隔\".encode() in res.data, \"Expected Chinese '检查间隔' for Time Between Check\"\n    assert b\"Time Between Check\" not in res.data, \"Should not have English 'Time Between Check'\"\n\n    # Test German translations\n    res = client.get(url_for(\"set_language\", locale=\"de\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    res = client.get(url_for(\"settings.settings_page\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    # Check that German translations are present\n    # German: Hours=Stunden, Minutes=Minuten, Seconds=Sekunden, Chrome Extension=Chrome-Erweiterung, Time Between Check=Prüfintervall\n    assert b\"Stunden\" in res.data, \"Expected German 'Stunden' for Hours\"\n    assert b\"Minuten\" in res.data, \"Expected German 'Minuten' for Minutes\"\n    assert b\"Sekunden\" in res.data, \"Expected German 'Sekunden' for Seconds\"\n    assert b\"Chrome-Erweiterung\" in res.data, \"Expected German 'Chrome-Erweiterung' for Chrome Extension\"\n    assert b\"Time Between Check\" not in res.data, \"Should not have English 'Time Between Check'\"\n\n    # Test Traditional Chinese (zh_Hant_TW) translations\n    res = client.get(url_for(\"set_language\", locale=\"zh_Hant_TW\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    res = client.get(url_for(\"settings.settings_page\"), follow_redirects=True)\n    assert res.status_code == 200\n\n    # Check that Traditional Chinese translations are present (not English)\n    # Traditional Chinese: Hours=小時, Minutes=分鐘, Seconds=秒, Chrome Extension=Chrome 擴充功能, Time Between Check=檢查間隔\n    assert \"小時\".encode() in res.data, \"Expected Traditional Chinese '小時' for Hours\"\n    assert \"分鐘\".encode() in res.data, \"Expected Traditional Chinese '分鐘' for Minutes\"\n    assert \"秒\".encode() in res.data, \"Expected Traditional Chinese '秒' for Seconds\"\n    assert \"Chrome 擴充功能\".encode() in res.data, \"Expected Traditional Chinese 'Chrome 擴充功能' for Chrome Extension\"\n    assert \"發送測試通知\".encode() in res.data, \"Expected Traditional Chinese '發送測試通知' for Send test notification\"\n    assert \"通知除錯記錄\".encode() in res.data, \"Expected Traditional Chinese '通知除錯記錄' for Notification debug logs\"\n    assert \"檢查間隔\".encode() in res.data, \"Expected Traditional Chinese '檢查間隔' for Time Between Check\"\n    # Make sure we don't have incorrect English text or wrong translations\n    assert b\"Send test notification\" not in res.data, \"Should not have English 'Send test notification'\"\n    assert b\"Time Between Check\" not in res.data, \"Should not have English 'Time Between Check'\"\n    assert \"Chrome 請求\".encode() not in res.data, \"Should not have incorrect 'Chrome 請求' (Chrome requests)\"\n    assert \"使用預設通知\".encode() not in res.data, \"Should not have incorrect '使用預設通知' (Use default notification)\"\n\n\ndef test_accept_language_header_zh_tw(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that browsers sending zh-TW in Accept-Language header get Traditional Chinese.\n    This tests the locale alias mapping for issue #3779.\n    \"\"\"\n    from flask import url_for\n\n    # Clear any session data to simulate a fresh visitor\n    with client.session_transaction() as sess:\n        sess.clear()\n\n    # Request the index page with zh-TW in Accept-Language header (what browsers send)\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        headers={'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should get Traditional Chinese content, not Simplified Chinese\n    # Traditional: 選擇語言, Simplified: 选择语言\n    assert '選擇語言'.encode() in res.data, \"Expected Traditional Chinese '選擇語言' (Select Language)\"\n    assert '选择语言'.encode() not in res.data, \"Should not get Simplified Chinese '选择语言'\"\n\n    # Check HTML lang attribute uses BCP 47 format\n    assert b'<html lang=\"zh-Hant-TW\"' in res.data, \"Expected BCP 47 language tag zh-Hant-TW in HTML\"\n\n    # Check that the correct flag icon is shown (Taiwan flag for Traditional Chinese)\n    assert b'<span class=\"fi fi-tw fis\" id=\"language-selector-flag\">' in res.data, \\\n        \"Expected Taiwan flag 'fi fi-tw' for Traditional Chinese\"\n    assert b'<span class=\"fi fi-cn fis\" id=\"language-selector-flag\">' not in res.data, \\\n        \"Should not show China flag 'fi fi-cn' for Traditional Chinese\"\n\n    # Verify we're getting Traditional Chinese text throughout the page\n    res = client.get(\n        url_for(\"settings.settings_page\"),\n        headers={'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Check Traditional Chinese translations (not English)\n    assert \"小時\".encode() in res.data, \"Expected Traditional Chinese '小時' for Hours\"\n    assert \"分鐘\".encode() in res.data, \"Expected Traditional Chinese '分鐘' for Minutes\"\n    assert b\"Hours\" not in res.data or \"小時\".encode() in res.data, \"Should have Traditional Chinese, not English\"\n\n\ndef test_accept_language_header_en_variants(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that browsers sending en-GB and en-US in Accept-Language header get the correct English variant.\n    This ensures the locale selector works properly for English variants.\n    \"\"\"\n    from flask import url_for\n\n    # Test 1: British English (en-GB)\n    with client.session_transaction() as sess:\n        sess.clear()\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        headers={'Accept-Language': 'en-GB,en;q=0.9'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should get English content\n    assert b\"Select Language\" in res.data, \"Expected English text 'Select Language'\"\n\n    # Check HTML lang attribute uses BCP 47 format with hyphen\n    assert b'<html lang=\"en-GB\"' in res.data, \"Expected BCP 47 language tag en-GB in HTML\"\n\n    # Check that the correct flag icon is shown (UK flag for en-GB)\n    assert b'<span class=\"fi fi-gb fis\" id=\"language-selector-flag\">' in res.data, \\\n        \"Expected UK flag 'fi fi-gb' for British English\"\n\n    # Test 2: American English (en-US)\n    with client.session_transaction() as sess:\n        sess.clear()\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        headers={'Accept-Language': 'en-US,en;q=0.9'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should get English content\n    assert b\"Select Language\" in res.data, \"Expected English text 'Select Language'\"\n\n    # Check HTML lang attribute uses BCP 47 format with hyphen\n    assert b'<html lang=\"en-US\"' in res.data, \"Expected BCP 47 language tag en-US in HTML\"\n\n    # Check that the correct flag icon is shown (US flag for en-US)\n    assert b'<span class=\"fi fi-us fis\" id=\"language-selector-flag\">' in res.data, \\\n        \"Expected US flag 'fi fi-us' for American English\"\n\n    # Test 3: Generic 'en' should fall back to one of the English variants\n    with client.session_transaction() as sess:\n        sess.clear()\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        headers={'Accept-Language': 'en'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should get English content (either variant is fine)\n    assert b\"Select Language\" in res.data, \"Expected English text 'Select Language'\"\n\n\ndef test_accept_language_header_zh_simplified(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that browsers sending zh or zh-CN in Accept-Language header get Simplified Chinese.\n    This ensures Simplified Chinese still works correctly and doesn't get confused with Traditional.\n    \"\"\"\n    from flask import url_for\n\n    # Test 1: Generic 'zh' should get Simplified Chinese\n    with client.session_transaction() as sess:\n        sess.clear()\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        headers={'Accept-Language': 'zh,en;q=0.9'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should get Simplified Chinese content, not Traditional Chinese\n    # Simplified: 选择语言, Traditional: 選擇語言\n    assert '选择语言'.encode() in res.data, \"Expected Simplified Chinese '选择语言' (Select Language)\"\n    assert '選擇語言'.encode() not in res.data, \"Should not get Traditional Chinese '選擇語言'\"\n\n    # Check HTML lang attribute\n    assert b'<html lang=\"zh\"' in res.data, \"Expected language tag zh in HTML\"\n\n    # Check that the correct flag icon is shown (China flag for Simplified Chinese)\n    assert b'<span class=\"fi fi-cn fis\" id=\"language-selector-flag\">' in res.data, \\\n        \"Expected China flag 'fi fi-cn' for Simplified Chinese\"\n    assert b'<span class=\"fi fi-tw fis\" id=\"language-selector-flag\">' not in res.data, \\\n        \"Should not show Taiwan flag 'fi fi-tw' for Simplified Chinese\"\n\n    # Test 2: 'zh-CN' should also get Simplified Chinese\n    with client.session_transaction() as sess:\n        sess.clear()\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        headers={'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should get Simplified Chinese content\n    assert '选择语言'.encode() in res.data, \"Expected Simplified Chinese '选择语言' with zh-CN header\"\n    assert '選擇語言'.encode() not in res.data, \"Should not get Traditional Chinese with zh-CN header\"\n\n    # Check that the correct flag icon is shown (China flag for zh-CN)\n    assert b'<span class=\"fi fi-cn fis\" id=\"language-selector-flag\">' in res.data, \\\n        \"Expected China flag 'fi fi-cn' for zh-CN header\"\n\n    # Verify Simplified Chinese in settings page\n    res = client.get(\n        url_for(\"settings.settings_page\"),\n        headers={'Accept-Language': 'zh,en;q=0.9'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Check Simplified Chinese translations (not Traditional or English)\n    # Simplified: 小时, Traditional: 小時\n    assert \"小时\".encode() in res.data, \"Expected Simplified Chinese '小时' for Hours\"\n    assert \"分钟\".encode() in res.data, \"Expected Simplified Chinese '分钟' for Minutes\"\n    assert \"秒\".encode() in res.data, \"Expected Simplified Chinese '秒' for Seconds\"\n    # Make sure it's not Traditional Chinese\n    assert \"小時\".encode() not in res.data, \"Should not have Traditional Chinese '小時'\"\n    assert \"分鐘\".encode() not in res.data, \"Should not have Traditional Chinese '分鐘'\"\n\n\ndef test_session_locale_overrides_accept_language(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that session locale preference overrides browser Accept-Language header.\n\n    Scenario:\n    1. Browser auto-detects zh-TW (Traditional Chinese) from Accept-Language header\n    2. User explicitly selects Korean language\n    3. On subsequent page loads, Korean should be shown (not Traditional Chinese)\n       even though the Accept-Language header still says zh-TW\n\n    This tests the session override behavior for issue #3779.\n    \"\"\"\n    from flask import url_for\n\n    # Step 1: Clear session and make first request with zh-TW header (auto-detect)\n    with client.session_transaction() as sess:\n        sess.clear()\n\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        headers={'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8'},\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should initially get Traditional Chinese from auto-detect\n    assert '選擇語言'.encode() in res.data, \"Expected Traditional Chinese '選擇語言' from auto-detect\"\n    assert b'<html lang=\"zh-Hant-TW\"' in res.data, \"Expected zh-Hant-TW language tag\"\n    assert b'<span class=\"fi fi-tw fis\" id=\"language-selector-flag\">' in res.data, \\\n        \"Expected Taiwan flag 'fi fi-tw' from auto-detect\"\n\n    # Step 2: User explicitly selects Korean language\n    res = client.get(\n        url_for(\"set_language\", locale=\"ko\"),\n        headers={'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8'},  # Browser still sends zh-TW\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Step 3: Make another request with same zh-TW header\n    # Session should override the Accept-Language header\n    res = client.get(\n        url_for(\"watchlist.index\"),\n        headers={'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8'},  # Still sending zh-TW!\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Should now get Korean (session overrides auto-detect)\n    # Korean: 언어 선택, Traditional Chinese: 選擇語言\n    assert '언어 선택'.encode() in res.data, \"Expected Korean '언어 선택' (Select Language) from session\"\n    assert '選擇語言'.encode() not in res.data, \"Should not get Traditional Chinese when Korean is set in session\"\n\n    # Check HTML lang attribute is Korean\n    assert b'<html lang=\"ko\"' in res.data, \"Expected Korean language tag 'ko' in HTML\"\n\n    # Check that Korean flag is shown (not Taiwan flag)\n    assert b'<span class=\"fi fi-kr fis\" id=\"language-selector-flag\">' in res.data, \\\n        \"Expected Korean flag 'fi fi-kr' from session preference\"\n    assert b'<span class=\"fi fi-tw fis\" id=\"language-selector-flag\">' not in res.data, \\\n        \"Should not show Taiwan flag when Korean is set in session\"\n\n    # Verify Korean text on settings page as well\n    res = client.get(\n        url_for(\"settings.settings_page\"),\n        headers={'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8'},  # Still zh-TW!\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n\n    # Check Korean translations (not Traditional Chinese or English)\n    # Korean: 시간 (Hours), 분 (Minutes), 초 (Seconds)\n    # Traditional Chinese: 小時, 分鐘, 秒\n    assert \"시간\".encode() in res.data, \"Expected Korean '시간' for Hours\"\n    assert \"분\".encode() in res.data, \"Expected Korean '분' for Minutes\"\n    assert \"小時\".encode() not in res.data, \"Should not have Traditional Chinese '小時' when Korean is set\"\n    assert \"分鐘\".encode() not in res.data, \"Should not have Traditional Chinese '分鐘' when Korean is set\"\n\n\ndef test_clear_history_translated_confirmation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that clearing snapshot history works with translated confirmation text.\n\n    Issue #3865: When the app language is set to German, the clear history\n    confirmation dialog shows the translated word (e.g. 'loschen') but the\n    backend only accepted the English word 'clear', making it impossible\n    to clear snapshots in non-English languages.\n    \"\"\"\n    from flask import url_for\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Add a watch so there is history to clear\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": test_url},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # Set language to German\n    res = client.get(\n        url_for(\"set_language\", locale=\"de\"),\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n\n    # Verify the clear history page shows the German confirmation word\n    res = client.get(\n        url_for(\"ui.clear_all_history\"),\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert \"löschen\".encode() in res.data, \"Expected German word 'loschen' on clear history page\"\n\n    # Submit the form with the German translated word\n    res = client.post(\n        url_for(\"ui.clear_all_history\"),\n        data={\"confirmtext\": \"löschen\"},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    # Should NOT show error message\n    assert b\"Incorrect confirmation text\" not in res.data, \\\n        \"German confirmation word 'loschen' should be accepted (issue #3865)\"\n\n    # Switch back to English and verify English word still works\n    res = client.get(\n        url_for(\"set_language\", locale=\"en_US\"),\n        follow_redirects=True\n    )\n\n    res = client.post(\n        url_for(\"ui.clear_all_history\"),\n        data={\"confirmtext\": \"clear\"},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b\"Incorrect confirmation text\" not in res.data, \\\n        \"English confirmation word 'clear' should still be accepted\"\n\n    # Verify that missing/empty confirmtext does not crash the server\n    res = client.post(\n        url_for(\"ui.clear_all_history\"),\n        data={},\n        follow_redirects=True\n    )\n    assert res.status_code == 200, \\\n        \"Missing confirmtext should not crash the server\"\n"
  },
  {
    "path": "changedetectionio/tests/test_ignore.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\nfrom changedetectionio import html_tools\nfrom . util import  extract_UUID_from_client\nimport os\n\ndef set_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <p>oh yeah 456</p>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef test_ignore(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    set_original_ignore_response(datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    # use the highlighter endpoint\n    res = client.post(\n        url_for(\"ui.ui_edit.highlight_submit_ignore_url\", uuid=uuid),\n        data={\"mode\": 'digit-regex', 'selection': 'oh yeah 123'},\n        follow_redirects=True\n    )\n\n    res = client.get(url_for(\"ui.ui_edit.edit_page\", uuid=uuid))\n    # should be a regex now\n    assert b'/oh\\ yeah\\ \\d+/' in res.data\n\n    # Should return a link\n    assert b'href' in res.data\n\n    # It should not be in the preview anymore\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=uuid))\n    assert b'<div class=\"ignored\">oh yeah 456' not in res.data\n\n    # Should be in base.html\n    assert b'csrftoken' in res.data\n\n\ndef test_strip_ignore_lines(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    set_original_ignore_response(datastore_path)\n\n\n    # Goto the settings page, add our ignore text\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-strip_ignored_lines\": \"y\",\n            \"application-global_ignore_text\": \"Which is across multiple\",\n            'application-fetch_backend': \"html_requests\"\n        },\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    # It should not be in the preview anymore\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=uuid))\n    assert b'<div class=\"ignored\">' not in res.data\n    assert b'Which is across multiple' not in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_ignore_regex_text.py",
    "content": "#!/usr/bin/env python3\n\nfrom . util import live_server_setup\nfrom changedetectionio import html_tools\n\n\n\n# Unit test of the stripper\n# Always we are dealing in utf-8\ndef test_strip_regex_text_func():\n    test_content = \"\"\"\n    but sometimes we want to remove the lines.\n    \n    but 1 lines\n    skip 5 lines\n    really? yes man\n#/not this tries weirdly formed regex or just strings starting with /\n/not this\n    but including 1234 lines\n    igNORe-cAse text we dont want to keep    \n    but not always.\"\"\"\n\n\n    ignore_lines = [\n        \"sometimes\",\n        \"/\\s\\d{2,3}\\s/\",\n        \"/ignore-case text/\",\n        \"really?\",\n        \"/skip \\d lines/i\",\n        \"/not\"\n    ]\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines)\n    assert \"but 1 lines\" in stripped_content\n    assert \"igNORe-cAse text\" not in stripped_content\n    assert \"but 1234 lines\" not in stripped_content\n    assert \"really\" not in stripped_content\n    assert \"not this\" not in stripped_content\n\n    # Check line number reporting\n    stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines, mode=\"line numbers\")\n    assert stripped_content == [2, 5, 6, 7, 8, 10]\n    \n    stripped_content = html_tools.strip_ignore_text(test_content, ['/but 1.+5 lines/s'])\n    assert \"but 1 lines\" not in stripped_content\n    assert \"skip 5 lines\" not in stripped_content\n    \n    stripped_content = html_tools.strip_ignore_text(test_content, ['/but 1.+5 lines/s'], mode=\"line numbers\")\n    assert stripped_content == [4, 5]\n    \n    stripped_content = html_tools.strip_ignore_text(test_content, ['/.+/s'])\n    assert stripped_content == \"\"\n    \n    stripped_content = html_tools.strip_ignore_text(test_content, ['/.+/s'], mode=\"line numbers\")\n    assert stripped_content == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ['/^.+but.+\\\\n.+lines$/m'])\n    assert \"but 1 lines\" not in stripped_content\n    assert \"skip 5 lines\" not in stripped_content\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ['/^.+but.+\\\\n.+lines$/m'], mode=\"line numbers\")\n    assert stripped_content == [4, 5]\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ['/^.+?\\.$/m'])\n    assert \"but sometimes we want to remove the lines.\" not in stripped_content\n    assert \"but not always.\" not in stripped_content\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ['/^.+?\\.$/m'], mode=\"line numbers\")\n    assert stripped_content == [2, 11]\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ['/but.+?but/ms'])\n    assert \"but sometimes we want to remove the lines.\" not in stripped_content\n    assert \"but 1 lines\" not in stripped_content\n    assert \"but 1234 lines\" not in stripped_content\n    assert \"igNORe-cAse text we dont want to keep\" not in stripped_content\n    assert \"but not always.\" not in stripped_content\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ['/but.+?but/ms'], mode=\"line numbers\")\n    assert stripped_content == [2, 3, 4, 9, 10, 11]\n\n    stripped_content = html_tools.strip_ignore_text(\"\\n\\ntext\\n\\ntext\\n\\n\", ['/^$/ms'], mode=\"line numbers\")\n    assert stripped_content == [1, 2, 4, 6]\n\n    # Check that linefeeds are preserved when there are is no matching ignores\n    content = \"some text\\n\\nand other text\\n\"\n    stripped_content = html_tools.strip_ignore_text(content, ignore_lines)\n    assert content == stripped_content\n"
  },
  {
    "path": "changedetectionio/tests/test_ignore_text.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nfrom changedetectionio import html_tools\nimport os\n\n\n\n# Unit test of the stripper\n# Always we are dealing in utf-8\ndef test_strip_text_func():\n    test_content = \"\"\"\n    Some content\n    is listed here\n\n    but sometimes we want to remove the lines.\n\n    but not always.\"\"\"\n\n    ignore_lines = [\"sometimes\"]\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines)\n    assert \"sometimes\" not in stripped_content\n    assert \"Some content\" in stripped_content\n\n    # Check that line feeds dont get chewed up when something is found\n    test_content = \"Some initial text\\n\\nWhich is across multiple lines\\n\\nZZZZz\\n\\n\\nSo let's see what happens.\"\n    ignore = ['something irrelevent but just to check', 'XXXXX', 'YYYYY', 'ZZZZZ']\n\n    stripped_content = html_tools.strip_ignore_text(test_content, ignore)\n    assert stripped_content == \"Some initial text\\n\\nWhich is across multiple lines\\n\\n\\n\\nSo let's see what happens.\"\n\ndef set_original_ignore_response(datastore_path, ver_stamp=\"123\"):\n    test_return_data = f\"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <link href=\"https://www.somesite/wp-content/themes/cooltheme/style2.css?v={ver_stamp}\" rel=\"stylesheet\"/>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef set_modified_original_ignore_response(datastore_path, ver_stamp=\"123\"):\n    test_return_data = f\"\"\"<html>\n       <body>\n     Some NEW nice initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <link href=\"https://www.somesite/wp-content/themes/cooltheme/style2.css?v={ver_stamp}\" rel=\"stylesheet\"/>\n     <p>new ignore stuff</p>\n     <p>blah</p>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n# Is the same but includes ZZZZZ, 'ZZZZZ' is the last line in ignore_text\ndef set_modified_ignore_response(datastore_path, ver_stamp=\"123\"):\n    test_return_data = f\"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <P>ZZZZz</P>\n     <br>\n     So let's see what happens.  <br>\n     <link href=\"https://www.somesite/wp-content/themes/cooltheme/style2.css?v={ver_stamp}\" rel=\"stylesheet\"/>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n# Ignore text now just removes it entirely, is a LOT more simpler code this way\n\ndef test_check_ignore_text_functionality(client, live_server, measure_memory_usage, datastore_path):\n\n    # Use a mix of case in ZzZ to prove it works case-insensitive.\n    ignore_text = \"XXXXX\\r\\nYYYYY\\r\\nzZzZZ\\r\\nnew ignore stuff\"\n    set_original_ignore_response(datastore_path=datastore_path)\n\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"ignore_text\": ignore_text, \"url\": test_url, 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n    )\n    assert bytes(ignore_text.encode('utf-8')) in res.data\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n    assert b'/test-endpoint' in res.data\n\n\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"))\n    # nothing ignored because none of the text matched\n    assert b'ignored_line_numbers = []' in res.data\n\n    #  Make a change\n    set_modified_ignore_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n    assert b'/test-endpoint' in res.data\n\n\n\n    # Just to be sure.. set a regular modified change..\n    set_modified_original_ignore_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"))\n\n    # SHOULD BE be in the preview, it was added in set_modified_original_ignore_response()\n    # and we have \"new ignore stuff\" in ignore_text\n    # it is only ignored, it is not removed (it will be highlighted too)\n    assert b'new ignore stuff' in res.data\n    # Data for the highlighting is present (this is done in JS for now)\n    assert b'ignored_line_numbers = [8]' in res.data\n\n    delete_all_watches(client)\n\n# When adding some ignore text, it should not trigger a change, even if something else on that line changes\ndef _run_test_global_ignore(client, datastore_path, as_source=False, extra_ignore=\"\"):\n    ignore_text = \"XXXXX\\r\\nYYYYY\\r\\nZZZZZ\\r\\n\"+extra_ignore\n\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Goto the settings page, add our ignore text\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-global_ignore_text\": ignore_text,\n            'application-fetch_backend': \"html_requests\"\n        },\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    if as_source:\n        # Switch to source mode so we can test that too!\n        test_url = \"source:\"+test_url\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    #Adding some ignore text should not trigger a change\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"ignore_text\": \"something irrelevent but just to check\", \"url\": test_url, 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n    # Check it saved\n    res = client.get(\n        url_for(\"settings.settings_page\"),\n    )\n\n    for i in ignore_text.splitlines():\n        assert bytes(i.encode('utf-8')) in res.data\n\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    # It should report nothing found (no new 'has-unread-changes' class), adding random ignore text should not cause a change\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n    assert b'/test-endpoint' in res.data\n#####\n\n    # Make a change which includes the ignore text, it should be ignored and no 'change' triggered\n    # It adds text with \"ZZZZzzzz\" and \"ZZZZ\" is in the ignore list\n    # And tweaks the ver_stamp which should be picked up by global regex ignore\n    set_modified_ignore_response(ver_stamp=time.time(), datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b'has-unread-changes' not in res.data\n    assert b'/test-endpoint' in res.data\n\n    # Just to be sure.. set a regular modified change that will trigger it\n    set_modified_original_ignore_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    delete_all_watches(client)\n\ndef test_check_global_ignore_text_functionality(client, live_server, measure_memory_usage, datastore_path):\n    \n    _run_test_global_ignore(client, as_source=False, datastore_path=datastore_path)\n\ndef test_check_global_ignore_text_functionality_as_source(client, live_server, measure_memory_usage, datastore_path):\n    \n    _run_test_global_ignore(client, as_source=True, extra_ignore='/\\?v=\\d/', datastore_path=datastore_path)\n"
  },
  {
    "path": "changedetectionio/tests/test_ignorehyperlinks.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Test suite for the render/not render anchor tag content functionality\"\"\"\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nimport os\n\n\ndef set_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <a href=\"/original_link\"> Some More Text </a>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n# Should be the same as set_original_ignore_response(datastore_path=datastore_path) but with a different\n# link\ndef set_modified_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <a href=\"/modified_link\"> Some More Text </a>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\ndef test_render_anchor_tag_content_true(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Testing that the link changes are detected when\n    render_anchor_tag_content setting is set to true\"\"\"\n\n    # Give the endpoint time to spin up\n    time.sleep(1)\n\n    # set original html text\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Goto the settings page, choose to ignore links (dont select/send \"application-render_anchor_tag_content\")\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-fetch_backend\": \"html_requests\",\n        },\n        follow_redirects=True,\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    test_url = url_for(\"test_endpoint\", _external=True)\n    res = client.post(\n        url_for(\"imports.import_page\"), data={\"urls\": test_url},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n\n    wait_for_all_checks(client)\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # set a new html text with a modified link\n    set_modified_ignore_response(datastore_path=datastore_path)\n    wait_for_all_checks(client)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # We should not see the rendered anchor tag\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"))\n    assert '(/modified_link)' not in res.data.decode()\n\n    # Goto the settings page, ENABLE render anchor tag\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-render_anchor_tag_content\": \"true\",\n            \"application-fetch_backend\": \"html_requests\",\n        },\n        follow_redirects=True,\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n\n\n    # check that the anchor tag content is rendered\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"))\n    assert '(/modified_link)' in res.data.decode()\n\n    # since the link has changed, and we chose to render anchor tag content,\n    # we should detect a change (new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b\"unviewed\" in res.data\n    assert b\"/test-endpoint\" in res.data\n\n    # Cleanup everything\n    delete_all_watches(client)\n\n"
  },
  {
    "path": "changedetectionio/tests/test_ignorestatuscode.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\nimport os\n\n\n\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef set_some_changed_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines, and a new thing too.</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef test_normal_page_check_works_with_ignore_status_code(client, live_server, measure_memory_usage, datastore_path):\n    from loguru import logger\n\n    set_original_response(datastore_path=datastore_path)\n\n    # Goto the settings page, add our ignore text\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_status_codes\": \"y\",\n            'application-fetch_backend': \"html_requests\"\n        },\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n\n    logger.info(f\"TEST: First check - queuing UUID {uuid}\")\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    logger.info(f\"TEST: Waiting for first check to complete\")\n    wait_result = wait_for_all_checks(client)\n    logger.info(f\"TEST: First check wait completed: {wait_result}\")\n\n    # Check history after first check\n    watch = client.application.config.get('DATASTORE').data['watching'][uuid]\n    logger.info(f\"TEST: After first check - history count: {len(watch.history.keys())}\")\n\n    set_some_changed_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    logger.info(f\"TEST: Second check - queuing UUID {uuid}\")\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    logger.info(f\"TEST: Waiting for second check to complete\")\n    wait_result = wait_for_all_checks(client)\n    logger.info(f\"TEST: Second check wait completed: {wait_result}\")\n\n    # Check history after second check\n    watch = client.application.config.get('DATASTORE').data['watching'][uuid]\n    logger.info(f\"TEST: After second check - history count: {len(watch.history.keys())}\")\n    logger.info(f\"TEST: Watch history keys: {list(watch.history.keys())}\")\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    if b'has-unread-changes' not in res.data:\n        logger.error(f\"TEST FAILED: has-unread-changes not found in response\")\n        logger.error(f\"TEST: Watch last_error: {watch.get('last_error')}\")\n        logger.error(f\"TEST: Watch last_checked: {watch.get('last_checked')}\")\n\n    assert b'has-unread-changes' in res.data\n    assert b'/test-endpoint' in res.data\n\n\n# Tests the whole stack works with staus codes ignored\ndef test_403_page_check_works_with_ignore_status_code(client, live_server, measure_memory_usage, datastore_path):\n\n    set_original_response(datastore_path=datastore_path)\n\n    # Give the endpoint time to spin up\n    time.sleep(1)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', status_code=403, _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # Goto the edit page, check our ignore option\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"ignore_status_codes\": \"y\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    #  Make a change\n    set_some_changed_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should have 'has-unread-changes' still\n    # Because it should be looking at only that 'sametext' id\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n"
  },
  {
    "path": "changedetectionio/tests/test_ignorewhitespace.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nimport os\n\nfrom .util import live_server_setup, delete_all_watches, wait_for_all_checks\n\n\n# Should be the same as set_original_ignore_response(datastore_path=datastore_path) but with a little more whitespacing\ndef set_original_ignore_response_but_with_whitespace(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>\n\n\n     Which is across multiple lines</p>\n     <br>\n     <br>\n\n         So let's see what happens.  <br>\n\n\n     </body>\n     </html>\n\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef set_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n\n# If there was only a change in the whitespacing, then we shouldnt have a change detected\ndef test_check_ignore_whitespace(client, live_server, measure_memory_usage, datastore_path):\n\n\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Goto the settings page, add our ignore text\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"requests-time_between_check-minutes\": 180,\n            \"application-ignore_whitespace\": \"y\",\n            \"application-fetch_backend\": \"html_requests\"\n        },\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    set_original_ignore_response_but_with_whitespace(datastore_path)\n    wait_for_all_checks(client)\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n    assert b'/test-endpoint' in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_import.py",
    "content": "#!/usr/bin/env python3\nimport io\nimport os\nimport time\n\nfrom flask import url_for\n\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\n\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\ndef test_import(client, live_server, measure_memory_usage, datastore_path):\n    # Give the endpoint time to spin up\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\n            \"distill-io\": \"\",\n            \"urls\": \"\"\"https://example.com\nhttps://example.com tag1\nhttps://example.com tag1, other tag\"\"\"\n        },\n        follow_redirects=True,\n    )\n    assert b\"3 Imported\" in res.data\n    assert b\"tag1\" in res.data\n    assert b\"other tag\" in res.data\n    delete_all_watches(client)\n\n    # Clear flask alerts\n    res = client.get( url_for(\"watchlist.index\"))\n    res = client.get( url_for(\"watchlist.index\"))\n\ndef xtest_import_skip_url(client, live_server, measure_memory_usage, datastore_path):\n\n\n    # Give the endpoint time to spin up\n    time.sleep(1)\n\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\n            \"distill-io\": \"\",\n            \"urls\": \"\"\"https://example.com\n:ht000000broken\n\"\"\"\n        },\n        follow_redirects=True,\n    )\n    assert b\"1 Imported\" in res.data\n    assert b\"ht000000broken\" in res.data\n    assert b\"1 Skipped\" in res.data\n    delete_all_watches(client)\n    # Clear flask alerts\n    res = client.get( url_for(\"watchlist.index\"))\n\ndef test_import_distillio(client, live_server, measure_memory_usage, datastore_path):\n\n    distill_data='''\n{\n    \"client\": {\n        \"local\": 1\n    },\n    \"data\": [\n        {\n            \"name\": \"Unraid | News\",\n            \"uri\": \"https://unraid.net/blog\",\n            \"config\": \"{\\\\\"selections\\\\\":[{\\\\\"frames\\\\\":[{\\\\\"index\\\\\":0,\\\\\"excludes\\\\\":[],\\\\\"includes\\\\\":[{\\\\\"type\\\\\":\\\\\"xpath\\\\\",\\\\\"expr\\\\\":\\\\\"(//div[@id='App']/div[contains(@class,'flex')]/main[contains(@class,'relative')]/section[contains(@class,'relative')]/div[@class='container']/div[contains(@class,'flex')]/div[contains(@class,'w-full')])[1]\\\\\"}]}],\\\\\"dynamic\\\\\":true,\\\\\"delay\\\\\":2}],\\\\\"ignoreEmptyText\\\\\":true,\\\\\"includeStyle\\\\\":false,\\\\\"dataAttr\\\\\":\\\\\"text\\\\\"}\",\n            \"tags\": [\"nice stuff\", \"nerd-news\"],\n            \"content_type\": 2,\n            \"state\": 40,\n            \"schedule\": \"{\\\\\"type\\\\\":\\\\\"INTERVAL\\\\\",\\\\\"params\\\\\":{\\\\\"interval\\\\\":4447}}\",\n            \"ts\": \"2022-03-27T15:51:15.667Z\"\n        }\n    ]\n}\t\t   \n\n'''\n\n    # Give the endpoint time to spin up\n    time.sleep(1)\n    delete_all_watches(client)\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\n            \"distill-io\": distill_data,\n            \"urls\" : ''\n        },\n        follow_redirects=True,\n    )\n\n\n    assert b\"Unable to read JSON file, was it broken?\" not in res.data\n    assert b\"1 Imported from Distill.io\" in res.data\n\n    res = client.get( url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"))\n\n    assert b\"https://unraid.net/blog\" in res.data\n    assert b\"Unraid | News\" in res.data\n\n\n    # flask/wtforms should recode this, check we see it\n    # wtforms encodes it like id=&#39 ,but html.escape makes it like id=&#x27\n    # - so just check it manually :(\n    #import json\n    #import html\n    #d = json.loads(distill_data)\n    # embedded_d=json.loads(d['data'][0]['config'])\n    # x=html.escape(embedded_d['selections'][0]['frames'][0]['includes'][0]['expr']).encode('utf-8')\n    assert b\"xpath:(//div[@id=&#39;App&#39;]/div[contains(@class,&#39;flex&#39;)]/main[contains(@class,&#39;relative&#39;)]/section[contains(@class,&#39;relative&#39;)]/div[@class=&#39;container&#39;]/div[contains(@class,&#39;flex&#39;)]/div[contains(@class,&#39;w-full&#39;)])[1]\" in res.data\n\n    # did the tags work?\n    res = client.get( url_for(\"watchlist.index\"))\n\n    # check tags\n    assert b\"nice stuff\" in res.data\n    assert b\"nerd-news\" in res.data\n\n    delete_all_watches(client)\n    # Clear flask alerts\n    res = client.get(url_for(\"watchlist.index\"))\n\ndef test_import_custom_xlsx(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test can upload a excel spreadsheet and the watches are created correctly\"\"\"\n\n    \n\n    dirname = os.path.dirname(__file__)\n    filename = os.path.join(dirname, 'import/spreadsheet.xlsx')\n    with open(filename, 'rb') as f:\n\n        data= {\n            'file_mapping': 'custom',\n            'custom_xlsx[col_0]': '1',\n            'custom_xlsx[col_1]': '3',\n            'custom_xlsx[col_2]': '5',\n            'custom_xlsx[col_3]': '4',\n            'custom_xlsx[col_type_0]': 'title',\n            'custom_xlsx[col_type_1]': 'url',\n            'custom_xlsx[col_type_2]': 'include_filters',\n            'custom_xlsx[col_type_3]': 'interval_minutes',\n            'xlsx_file': (io.BytesIO(f.read()), 'spreadsheet.xlsx')\n        }\n\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data=data,\n        follow_redirects=True,\n    )\n\n    assert b'4 imported from custom .xlsx' in res.data\n    # Because this row was actually just a header with no usable URL, we should get an error\n    assert b'Error processing row number 1' in res.data\n\n    res = client.get(\n        url_for(\"watchlist.index\")\n    )\n\n    assert b'Somesite results ABC' in res.data\n    assert b'City news results' in res.data\n\n    # Just find one to check over\n    for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items():\n        if watch.get('title') == 'Somesite results ABC':\n            filters = watch.get('include_filters')\n            assert filters[0] == '/html[1]/body[1]/div[4]/div[1]/div[1]/div[1]||//*[@id=\\'content\\']/div[3]/div[1]/div[1]||//*[@id=\\'content\\']/div[1]'\n            assert watch.get('time_between_check') == {'weeks': 0, 'days': 1, 'hours': 6, 'minutes': 24, 'seconds': 0}\n\n    delete_all_watches(client)\n\ndef test_import_watchete_xlsx(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test can upload a excel spreadsheet and the watches are created correctly\"\"\"\n\n    \n    dirname = os.path.dirname(__file__)\n    filename = os.path.join(dirname, 'import/spreadsheet.xlsx')\n    with open(filename, 'rb') as f:\n\n        data= {\n            'file_mapping': 'wachete',\n            'xlsx_file': (io.BytesIO(f.read()), 'spreadsheet.xlsx')\n        }\n\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data=data,\n        follow_redirects=True,\n    )\n\n    assert b'4 imported from Wachete .xlsx' in res.data\n\n    res = client.get(\n        url_for(\"watchlist.index\")\n    )\n\n    assert b'Somesite results ABC' in res.data\n    assert b'City news results' in res.data\n\n    # Just find one to check over\n    for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items():\n        if watch.get('title') == 'Somesite results ABC':\n            filters = watch.get('include_filters')\n            assert filters[0] == '/html[1]/body[1]/div[4]/div[1]/div[1]/div[1]||//*[@id=\\'content\\']/div[3]/div[1]/div[1]||//*[@id=\\'content\\']/div[1]'\n            assert watch.get('time_between_check') == {'weeks': 0, 'days': 1, 'hours': 6, 'minutes': 24, 'seconds': 0}\n            assert watch.get('fetch_backend') == 'html_requests' # Has inactive 'dynamic wachet'\n\n        if watch.get('title') == 'JS website':\n            assert watch.get('fetch_backend') == 'html_webdriver' # Has active 'dynamic wachet'\n\n        if watch.get('title') == 'system default website':\n            assert watch.get('fetch_backend') == 'system' # uses default if blank\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_jinja2.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nimport arrow\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\nfrom ..jinja2_custom import render\n\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   # #  live_server_setup(live_server) # Setup on conftest per function\n\n# If there was only a change in the whitespacing, then we shouldnt have a change detected\ndef test_jinja2_in_url_query(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    # Add our URL to the import page\n    test_url = url_for('test_return_query', _external=True)\n\n    # because url_for() will URL-encode the var, but we dont here\n    full_url = \"{}?{}\".format(test_url,\n                              \"date={% now 'Europe/Berlin', '%Y' %}.{% now 'Europe/Berlin', '%m' %}.{% now 'Europe/Berlin', '%d' %}\", )\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": full_url, \"tags\": \"test\"},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b'date=2' in res.data\n\n# Test for issue #1493 - jinja2-time offset functionality\ndef test_jinja2_time_offset_in_url_query(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that jinja2 time offset expressions work in watch URLs (issue #1493).\"\"\"\n\n    # Add our URL to the import page with time offset expression\n    test_url = url_for('test_return_query', _external=True)\n\n    # Test the exact syntax from issue #1493 that was broken in jinja2-time\n    # This should work now with our custom TimeExtension\n    full_url = \"{}?{}\".format(test_url,\n                              \"timestamp={% now 'utc' - 'minutes=11', '%Y-%m-%d %H:%M' %}\", )\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": full_url, \"tags\": \"test\"},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n    wait_for_all_checks(client)\n\n    # Verify the URL was processed correctly (should not have errors)\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    # Should have a valid timestamp in the response\n    assert b'timestamp=' in res.data\n    # Should not have template error\n    assert b'Invalid template' not in res.data\n\n\n# https://techtonics.medium.com/secure-templating-with-jinja2-understanding-ssti-and-jinja2-sandbox-environment-b956edd60456\ndef test_jinja2_security_url_query(client, live_server, measure_memory_usage, datastore_path):\n    # Add our URL to the import page\n    test_url = url_for('test_return_query', _external=True)\n\n    full_url = test_url + \"?date={{ ''.__class__.__mro__[1].__subclasses__()}}\"\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": full_url, \"tags\": \"test\"},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" not in res.data\n\n\ndef test_timezone(mocker):\n    \"\"\"Verify that timezone is parsed.\"\"\"\n\n    timezone = 'America/Buenos_Aires'\n    currentDate = arrow.now(timezone)\n    arrowNowMock = mocker.patch(\"changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now\")\n    arrowNowMock.return_value = currentDate\n    finalRender = render(f\"{{% now '{timezone}' %}}\")\n\n    assert finalRender == currentDate.strftime('%a, %d %b %Y %H:%M:%S')\n\ndef test_format(mocker):\n    \"\"\"Verify that format is parsed.\"\"\"\n\n    timezone = 'utc'\n    format = '%d %b %Y %H:%M:%S'\n    currentDate = arrow.now(timezone)\n    arrowNowMock = mocker.patch(\"arrow.now\")\n    arrowNowMock.return_value = currentDate\n    finalRender = render(f\"{{% now '{timezone}', '{format}' %}}\")\n\n    assert finalRender == currentDate.strftime(format)\n\ndef test_add_time(environment):\n    \"\"\"Verify that added time offset can be parsed.\"\"\"\n\n    finalRender = render(\"{% now 'utc' + 'hours=2,seconds=30' %}\")\n\n    assert finalRender == \"Thu, 10 Dec 2015 01:33:31\"\n\ndef test_add_weekday(mocker):\n    \"\"\"Verify that added weekday offset can be parsed.\"\"\"\n\n    timezone = 'utc'\n    currentDate = arrow.now(timezone)\n    arrowNowMock = mocker.patch(\"changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now\")\n    arrowNowMock.return_value = currentDate\n    finalRender = render(f\"{{% now '{timezone}' + 'weekday=1' %}}\")\n\n    assert finalRender == currentDate.shift(weekday=1).strftime('%a, %d %b %Y %H:%M:%S')\n\n\ndef test_substract_time(environment):\n    \"\"\"Verify that substracted time offset can be parsed.\"\"\"\n\n    finalRender = render(\"{% now 'utc' - 'minutes=11' %}\")\n\n    assert finalRender == \"Wed, 09 Dec 2015 23:22:01\"\n\n\ndef test_offset_with_format(environment):\n    \"\"\"Verify that offset works together with datetime format.\"\"\"\n\n    finalRender = render(\n        \"{% now 'utc' - 'days=2,minutes=33,seconds=1', '%d %b %Y %H:%M:%S' %}\"\n    )\n\n    assert finalRender == \"07 Dec 2015 23:00:00\"\n\ndef test_default_timezone_empty_string(environment):\n    \"\"\"Verify that empty timezone string uses the default timezone (UTC in test environment).\"\"\"\n\n    # Empty string should use the default timezone which is 'UTC' (or from application settings)\n    finalRender = render(\"{% now '' %}\")\n\n    # Should render with default format and UTC timezone (matches environment fixture)\n    assert finalRender == \"Wed, 09 Dec 2015 23:33:01\"\n\ndef test_default_timezone_with_offset(environment):\n    \"\"\"Verify that empty timezone works with offset operations.\"\"\"\n\n    # Empty string with offset should use default timezone\n    finalRender = render(\"{% now '' + 'hours=2', '%d %b %Y %H:%M:%S' %}\")\n\n    assert finalRender == \"10 Dec 2015 01:33:01\"\n\ndef test_default_timezone_subtraction(environment):\n    \"\"\"Verify that empty timezone works with subtraction offset.\"\"\"\n\n    finalRender = render(\"{% now '' - 'minutes=11' %}\")\n\n    assert finalRender == \"Wed, 09 Dec 2015 23:22:01\"\n\ndef test_regex_replace_basic():\n    \"\"\"Test basic regex_replace functionality.\"\"\"\n\n    # Simple word replacement\n    finalRender = render(\"{{ 'hello world' | regex_replace('world', 'universe') }}\")\n    assert finalRender == \"hello universe\"\n\ndef test_regex_replace_with_groups():\n    \"\"\"Test regex_replace with capture groups (issue #3501 use case).\"\"\"\n\n    # Transform HTML table data as described in the issue\n    template = \"{{ '<td>thing</td><td>other</td>' | regex_replace('<td>([^<]+)</td><td>([^<]+)</td>', 'ThingLabel: \\\\\\\\1\\\\nOtherLabel: \\\\\\\\2') }}\"\n    finalRender = render(template)\n    assert \"ThingLabel: thing\" in finalRender\n    assert \"OtherLabel: other\" in finalRender\n\ndef test_regex_replace_multiple_matches():\n    \"\"\"Test regex_replace replacing multiple occurrences.\"\"\"\n\n    finalRender = render(\"{{ 'foo bar foo baz' | regex_replace('foo', 'qux') }}\")\n    assert finalRender == \"qux bar qux baz\"\n\ndef test_regex_replace_count_parameter():\n    \"\"\"Test regex_replace with count parameter to limit replacements.\"\"\"\n\n    finalRender = render(\"{{ 'foo bar foo baz' | regex_replace('foo', 'qux', 1) }}\")\n    assert finalRender == \"qux bar foo baz\"\n\ndef test_regex_replace_empty_replacement():\n    \"\"\"Test regex_replace with empty replacement (removal).\"\"\"\n\n    finalRender = render(\"{{ 'hello world 123' | regex_replace('[0-9]+', '') }}\")\n    assert finalRender == \"hello world \"\n\ndef test_regex_replace_no_match():\n    \"\"\"Test regex_replace when pattern doesn't match.\"\"\"\n\n    finalRender = render(\"{{ 'hello world' | regex_replace('xyz', 'abc') }}\")\n    assert finalRender == \"hello world\"\n\ndef test_regex_replace_invalid_regex():\n    \"\"\"Test regex_replace with invalid regex pattern returns original value.\"\"\"\n\n    # Invalid regex (unmatched parenthesis)\n    finalRender = render(\"{{ 'hello world' | regex_replace('(invalid', 'replacement') }}\")\n    assert finalRender == \"hello world\"\n\ndef test_regex_replace_special_characters():\n    \"\"\"Test regex_replace with special regex characters.\"\"\"\n\n    finalRender = render(\"{{ 'Price: $50.00' | regex_replace('\\\\\\\\$([0-9.]+)', 'USD \\\\\\\\1') }}\")\n    assert finalRender == \"Price: USD 50.00\"\n\ndef test_regex_replace_multiline():\n    \"\"\"Test regex_replace on multiline text.\"\"\"\n\n    template = \"{{ 'line1\\\\nline2\\\\nline3' | regex_replace('^line', 'row') }}\"\n    finalRender = render(template)\n    # By default re.sub doesn't use MULTILINE flag, so only first line matches with ^\n    assert finalRender == \"row1\\nline2\\nline3\"\n\ndef test_regex_replace_with_notification_context():\n    \"\"\"Test regex_replace with notification diff variable.\"\"\"\n\n    # Simulate how it would be used in notifications with diff variable\n    from changedetectionio.notification_service import NotificationContextData\n\n    context = NotificationContextData()\n    context['diff'] = '<td>value1</td><td>value2</td>'\n\n    template = \"{{ diff | regex_replace('<td>([^<]+)</td>', '\\\\\\\\1 ') }}\"\n\n    from changedetectionio.jinja2_custom import create_jinja_env\n    from jinja2 import BaseLoader\n\n    jinja2_env = create_jinja_env(loader=BaseLoader)\n    jinja2_env.globals.update(context)\n    finalRender = jinja2_env.from_string(template).render()\n\n    assert \"value1 value2 \" in finalRender\n\ndef test_regex_replace_security_large_input():\n    \"\"\"Test regex_replace handles large input safely.\"\"\"\n\n    # Create a large input string (over 10MB)\n    large_input = \"x\" * (1024 * 1024 * 10 + 1000)\n    template = \"{{ large_input | regex_replace('x', 'y') }}\"\n\n    from changedetectionio.jinja2_custom import create_jinja_env\n    from jinja2 import BaseLoader\n\n    jinja2_env = create_jinja_env(loader=BaseLoader)\n    jinja2_env.globals['large_input'] = large_input\n    finalRender = jinja2_env.from_string(template).render()\n\n    # Should be truncated to 10MB\n    assert len(finalRender) == 1024 * 1024 * 10\n\ndef test_regex_replace_security_long_pattern():\n    \"\"\"Test regex_replace rejects very long patterns.\"\"\"\n\n    # Pattern longer than 500 chars should be rejected\n    long_pattern = \"a\" * 501\n    finalRender = render(\"{{ 'test' | regex_replace('\" + long_pattern + \"', 'replacement') }}\")\n\n    # Should return original value when pattern is too long\n    assert finalRender == \"test\"\n\ndef test_regex_replace_security_dangerous_pattern():\n    \"\"\"Test regex_replace detects and rejects dangerous nested quantifiers.\"\"\"\n\n    # Patterns that could cause catastrophic backtracking\n    dangerous_patterns = [\n        \"(a+)+\",\n        \"(a*)+\",\n        \"(a+)*\",\n        \"(a*)*\",\n    ]\n\n    for dangerous in dangerous_patterns:\n        # Create a template with the dangerous pattern\n        # Using single quotes to avoid escaping issues\n        from changedetectionio.jinja2_custom import create_jinja_env\n        from jinja2 import BaseLoader\n\n        jinja2_env = create_jinja_env(loader=BaseLoader)\n        jinja2_env.globals['pattern'] = dangerous\n        template = \"{{ 'aaaaaaaaaa' | regex_replace(pattern, 'x') }}\"\n        finalRender = jinja2_env.from_string(template).render()\n\n        # Should return original value when dangerous pattern is detected\n        assert finalRender == \"aaaaaaaaaa\"\n\ndef test_regex_replace_security_timeout_protection():\n    \"\"\"Test that regex_replace has timeout protection (if SIGALRM available).\"\"\"\n    import signal\n\n    # Only test on systems that support SIGALRM\n    if not hasattr(signal, 'SIGALRM'):\n        # Skip test on Windows and other systems without SIGALRM\n        return\n\n    # This pattern is known to cause exponential backtracking on certain inputs\n    # but should be caught by our dangerous pattern detector\n    # We're mainly testing that the timeout mechanism works\n\n    from changedetectionio.jinja2_custom import regex_replace\n\n    # Create input that could trigger slow regex\n    test_input = \"a\" * 50 + \"b\"\n\n    # This shouldn't take long due to our protections\n    result = regex_replace(test_input, \"a+b\", \"x\")\n\n    # Should complete and return a result\n    assert result is not None"
  },
  {
    "path": "changedetectionio/tests/test_jsonpath_jq_selector.py",
    "content": "#!/usr/bin/env python3\n# coding=utf-8\n\nimport time\nfrom flask import url_for\nfrom markupsafe import escape\nfrom . util import live_server_setup, wait_for_all_checks, delete_all_watches\nimport pytest\nimport os\njq_support = True\n\ntry:\n    import jq\nexcept ModuleNotFoundError:\n    jq_support = False\n\n\n\ndef test_jsonp_treated_as_plaintext():\n    from ..processors.magic import guess_stream_type\n\n    # JSONP content (server wrongly claims application/json) should be detected as plaintext\n    # Callback names are arbitrary identifiers, not always 'cb'\n    jsonp_content = 'jQuery123456({ \"version\": \"8.0.41\", \"url\": \"https://example.com/app.apk\" })'\n    result = guess_stream_type(http_content_header=\"application/json\", content=jsonp_content)\n    assert result.is_json is False\n    assert result.is_plaintext is True\n\n    # Variation with dotted callback name e.g. jQuery.cb(...)\n    jsonp_dotted = 'some.callback({ \"version\": \"1.0\" })'\n    result = guess_stream_type(http_content_header=\"application/json\", content=jsonp_dotted)\n    assert result.is_json is False\n    assert result.is_plaintext is True\n\n    # Real JSON should still be detected as JSON\n    json_content = '{ \"version\": \"8.0.41\", \"url\": \"https://example.com/app.apk\" }'\n    result = guess_stream_type(http_content_header=\"application/json\", content=json_content)\n    assert result.is_json is True\n    assert result.is_plaintext is False\n\n\ndef test_jsonp_json_filter_extraction():\n    from .. import html_tools\n\n    # Tough case: dotted namespace callback, trailing semicolon, deeply nested content with arrays\n    jsonp_content = 'weixin.update.callback({\"platforms\": {\"android\": {\"variants\": [{\"arch\": \"arm64\", \"versionName\": \"8.0.68\", \"url\": \"https://example.com/app-arm64.apk\"}, {\"arch\": \"arm32\", \"versionName\": \"8.0.41\", \"url\": \"https://example.com/app-arm32.apk\"}]}}});'\n\n    # Deep nested jsonpath filter into array element\n    text = html_tools.extract_json_as_string(jsonp_content, \"json:$.platforms.android.variants[0].versionName\")\n    assert text == '\"8.0.68\"'\n\n    # Filter that selects the second array element\n    text = html_tools.extract_json_as_string(jsonp_content, \"json:$.platforms.android.variants[1].arch\")\n    assert text == '\"arm32\"'\n\n    if jq_support:\n        text = html_tools.extract_json_as_string(jsonp_content, \"jq:.platforms.android.variants[0].versionName\")\n        assert text == '\"8.0.68\"'\n\n        text = html_tools.extract_json_as_string(jsonp_content, \"jqraw:.platforms.android.variants[1].url\")\n        assert text == \"https://example.com/app-arm32.apk\"\n\n\ndef test_unittest_inline_html_extract():\n    # So lets pretend that the JSON we want is inside some HTML\n    content=\"\"\"\n    <html>\n    \n    food and stuff and more\n    <script>\n    alert('nothing really good here');\n    </script>\n    \n    <script type=\"application/ld+json\">\n  xx {\"@context\":\"http://schema.org\",\"@type\":\"Product\",\"name\":\"Nan Optipro Stage 1 Baby Formula  800g\",\"description\":\"During the first year of life, nutrition is critical for your baby. NAN OPTIPRO 1 is tailored to ensure your formula fed infant receives balanced, high quality nutrition.<br />Starter infant formula. The age optimised protein source (whey dominant) is from cow’s milk.<br />Backed by more than 150 years of Nestlé expertise.<br />For hygiene and convenience, it is available in an innovative packaging format with a separate storage area for the scoop, and a semi-transparent window which allows you to see how much powder is left in the can without having to open it.\",\"image\":\"https://cdn0.woolworths.media/content/wowproductimages/large/155536.jpg\",\"brand\":{\"@context\":\"http://schema.org\",\"@type\":\"Organization\",\"name\":\"Nan\"},\"gtin13\":\"7613287517388\",\"offers\":{\"@context\":\"http://schema.org\",\"@type\":\"Offer\",\"potentialAction\":{\"@context\":\"http://schema.org\",\"@type\":\"BuyAction\"},\"availability\":\"http://schema.org/InStock\",\"itemCondition\":\"http://schema.org/NewCondition\",\"price\":23.5,\"priceCurrency\":\"AUD\"},\"review\":[],\"sku\":\"155536\"}\n</script>\n<body>\nand it can also be repeated\n<script type=\"application/ld+json\">\n  {\"@context\":\"http://schema.org\",\"@type\":\"Product\",\"name\":\"Nan Optipro Stage 1 Baby Formula  800g\",\"description\":\"During the first year of life, nutrition is critical for your baby. NAN OPTIPRO 1 is tailored to ensure your formula fed infant receives balanced, high quality nutrition.<br />Starter infant formula. The age optimised protein source (whey dominant) is from cow’s milk.<br />Backed by more than 150 years of Nestlé expertise.<br />For hygiene and convenience, it is available in an innovative packaging format with a separate storage area for the scoop, and a semi-transparent window which allows you to see how much powder is left in the can without having to open it.\",\"image\":\"https://cdn0.woolworths.media/content/wowproductimages/large/155536.jpg\",\"brand\":{\"@context\":\"http://schema.org\",\"@type\":\"Organization\",\"name\":\"Nan\"},\"gtin13\":\"7613287517388\",\"offers\":{\"@context\":\"http://schema.org\",\"@type\":\"Offer\",\"potentialAction\":{\"@context\":\"http://schema.org\",\"@type\":\"BuyAction\"},\"availability\":\"http://schema.org/InStock\",\"itemCondition\":\"http://schema.org/NewCondition\",\"price\":23.5,\"priceCurrency\":\"AUD\"},\"review\":[],\"sku\":\"155536\"}\n</script>\n<h4>ok</h4>\n</body>\n</html>\n\n    \"\"\"\n    from .. import html_tools\n\n    # See that we can find the second <script> one, which is not broken, and matches our filter\n    text = html_tools.extract_json_as_string(content, \"json:$.offers.priceCurrency\")\n    assert text == '\"AUD\"'\n\n    text = html_tools.extract_json_as_string('{\"id\":5}', \"json:$.id\")\n    assert text == \"5\"\n\n    # also check for jq\n    if jq_support:\n        text = html_tools.extract_json_as_string(content, \"jq:.offers.priceCurrency\")\n        assert text == '\"AUD\"'\n\n        text = html_tools.extract_json_as_string('{\"id\":5}', \"jq:.id\")\n        assert text == \"5\"\n\n        text = html_tools.extract_json_as_string(content, \"jqraw:.offers.priceCurrency\")\n        assert text == \"AUD\"\n\n        text = html_tools.extract_json_as_string('{\"id\":5}', \"jqraw:.id\")\n        assert text == \"5\"\n\n\n    # When nothing at all is found, it should throw JSONNOTFound\n    # Which is caught and shown to the user in the watch-overview table\n    with pytest.raises(html_tools.JSONNotFound) as e_info:\n        html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', \"json:$.id\")\n\n    if jq_support:\n        with pytest.raises(html_tools.JSONNotFound) as e_info:\n            html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', \"jq:.id\")\n\n        with pytest.raises(html_tools.JSONNotFound) as e_info:\n            html_tools.extract_json_as_string('COMPLETE GIBBERISH, NO JSON!', \"jqraw:.id\")\n\n\ndef test_unittest_inline_extract_body():\n    content = \"\"\"\n    <html>\n        <head></head>\n        <body>\n            <pre style=\"word-wrap: break-word; white-space: pre-wrap;\">\n                {\"testKey\": 42}\n            </pre>\n        </body>\n    </html>\n    \"\"\"\n    from .. import html_tools\n\n    # See that we can find the second <script> one, which is not broken, and matches our filter\n    text = html_tools.extract_json_as_string(content, \"json:$.testKey\")\n    assert text == '42'\n\ndef set_original_ext_response(datastore_path):\n    data = \"\"\"\n        [\n        {\n            \"isPriceLowered\": false,\n            \"status\": \"ForSale\",\n            \"statusOrig\": \"for sale\"\n        },\n        {\n            \"_id\": \"5e7b3e1fb3262d306323ff1e\",\n            \"listingsType\": \"consumer\",\n            \"status\": \"ForSale\",\n            \"statusOrig\": \"for sale\"\n        }\n    ]\n        \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(data)\n    return None\n\ndef set_modified_ext_response(datastore_path):\n    # This should get reformatted\n    data = \"\"\" [ { \"isPriceLowered\": false,  \"status\": \"Sold\",  \"statusOrig\": \"sold\" }, {\n        \"_id\": \"5e7b3e1fb3262d306323ff1e\",\n        \"listingsType\": \"consumer\",\n        \"isPriceLowered\": false,\n        \"status\": \"Sold\"\n    }\n]\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(data)\n    return None\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"\n    {\n      \"employees\": [\n        {\n          \"id\": 1,\n          \"name\": \"Pankaj\",\n          \"salary\": \"10000\"\n        },\n        {\n          \"name\": \"David\",\n          \"salary\": \"5000\",\n          \"id\": 2\n        }\n      ],\n      \"boss\": {\n        \"name\": \"Fat guy\"\n      },\n      \"available\": true\n    }\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef set_json_response_with_html(datastore_path):\n    test_return_data = \"\"\"\n    {\n      \"test\": [\n        {\n          \"html\": \"<b>\"\n        }\n      ]\n    }\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"\n    {\n      \"employees\": [\n        {\n          \"id\": 1,\n          \"name\": \"Pankaj\",\n          \"salary\": \"10000\"\n        },\n        {\n          \"name\": \"David\",\n          \"salary\": \"5000\",\n          \"id\": 2\n        }\n      ],\n      \"boss\": {\n        \"name\": \"Örnsköldsvik\"\n      },\n      \"available\": false\n    }\n        \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    return None\n\ndef test_check_json_without_filter(client, live_server, measure_memory_usage, datastore_path):\n    # Request a JSON document from a application/json source containing HTML\n    # and be sure it doesn't get chewed up by instriptis\n    set_json_response_with_html(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"application/json\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # Should still see '\"html\": \"<b>\"'\n    assert b'&#34;html&#34;: &#34;&lt;b&gt;&#34;' in res.data\n    assert res.data.count(b'{') >= 2\n\n    delete_all_watches(client)\n\ndef check_json_filter(json_filter, client, live_server, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n\n    delete_all_watches(client)\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"application/json\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={\"include_filters\": json_filter.splitlines()})\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n    )\n    assert bytes(escape(json_filter).encode('utf-8')) in res.data\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n    #  Make a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should have 'has-unread-changes' still\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    # Should not see this, because its not in the JSONPath we entered\n    res = client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=uuid))\n\n    # But the change should be there, tho its hard to test the change was detected because it will show old and new versions\n    # And #462 - check we see the proper utf-8 string there\n    assert \"Örnsköldsvik\".encode('utf-8') in res.data\n\n    delete_all_watches(client)\n\ndef test_check_jsonpath_filter(client, live_server, measure_memory_usage, datastore_path):\n    check_json_filter('json:boss.name', client, live_server, datastore_path=datastore_path)\n\ndef test_check_jq_filter(client, live_server, measure_memory_usage, datastore_path):\n    if jq_support:\n        check_json_filter('jq:.boss.name', client, live_server, datastore_path=datastore_path)\n\ndef test_check_jqraw_filter(client, live_server, measure_memory_usage, datastore_path):\n    if jq_support:\n        check_json_filter('jqraw:.boss.name', client, live_server, datastore_path=datastore_path)\n\ndef check_json_filter_bool_val(json_filter, client, live_server, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n    test_url = url_for('test_endpoint', content_type=\"application/json\", _external=True)\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={\"include_filters\": [json_filter]})\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    #  Make a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n    # But the change should be there, tho its hard to test the change was detected because it will show old and new versions\n    assert b'false' in res.data\n\n    delete_all_watches(client)\n\ndef test_check_jsonpath_filter_bool_val(client, live_server, measure_memory_usage, datastore_path):\n    check_json_filter_bool_val(\"json:$['available']\", client, live_server, datastore_path=datastore_path)\n    delete_all_watches(client)\n\ndef test_check_jq_filter_bool_val(client, live_server, measure_memory_usage, datastore_path):\n    if jq_support:\n        check_json_filter_bool_val(\"jq:.available\", client, live_server, datastore_path=datastore_path)\n    delete_all_watches(client)\n\ndef test_check_jqraw_filter_bool_val(client, live_server, measure_memory_usage, datastore_path):\n    if jq_support:\n        check_json_filter_bool_val(\"jq:.available\", client, live_server, datastore_path=datastore_path)\n    delete_all_watches(client)\n\n# Re #265 - Extended JSON selector test\n# Stuff to consider here\n# - Selector should be allowed to return empty when it doesnt match (people might wait for some condition)\n# - The 'diff' tab could show the old and new content\n# - Form should let us enter a selector that doesnt (yet) match anything\ndef check_json_ext_filter(json_filter, client, live_server, datastore_path):\n    set_original_ext_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"application/json\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\"include_filters\": json_filter,\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"\n              },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n    )\n    assert bytes(escape(json_filter).encode('utf-8')) in res.data\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n    #  Make a change\n    set_modified_ext_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    dates = list(watch.history.keys())\n    snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])\n\n    assert snapshot_contents[0] == '['\n\n    # It should have 'has-unread-changes'\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"))\n\n    # We should never see 'ForSale' because we are selecting on 'Sold' in the rule,\n    # But we should know it triggered ('has-unread-changes' assert above)\n    assert b'ForSale' not in res.data\n    assert b'Sold' in res.data\n\n\n    # And the difference should have both?\n\n    res = client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n    assert b'ForSale' in res.data\n    assert b'Sold' in res.data\n\n    delete_all_watches(client)\n\ndef test_ignore_json_order(client, live_server, measure_memory_usage, datastore_path):\n    # A change in order shouldn't trigger a notification\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write('{\"hello\" : 123, \"world\": 123}')\n\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=\"application/json\", _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write('{\"world\" : 123, \"hello\": 123}')\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # Just to be sure it still works\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write('{\"world\" : 123, \"hello\": 124}')\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    delete_all_watches(client)\n\ndef test_correct_header_detect(client, live_server, measure_memory_usage, datastore_path):\n    # Like in https://github.com/dgtlmoon/changedetection.io/pull/1593\n    # Specify extra html that JSON is sometimes wrapped in - when using SockpuppetBrowser / Puppeteer / Playwrightetc\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write('<html><body>{ \"world\": 123, \"hello\" : 123}')\n\n    # Add our URL to the import page\n    # Check weird casing is cleaned up and detected also\n    test_url = url_for('test_endpoint', content_type=\"aPPlication/JSon\", uppercase_headers=True, _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    # Fixed in #1593\n    assert b'No parsable JSON found in this document' not in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    dates = list(watch.history.keys())\n    snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])\n\n    assert b'&#34;hello&#34;: 123,' in res.data # properly html escaped in the front end\n    import json\n    data = json.loads(snapshot_contents)\n    keys = list(data.keys())\n    # Should be correctly formatted and sorted,  (\"world\" goes to end)\n    assert keys == [\"hello\", \"world\"]\n        \n    delete_all_watches(client)\n\ndef test_check_jsonpath_ext_filter(client, live_server, measure_memory_usage, datastore_path):\n    check_json_ext_filter('json:$[?(@.status==Sold)]', client, live_server, datastore_path=datastore_path)\n    delete_all_watches(client)\n\ndef test_check_jq_ext_filter(client, live_server, measure_memory_usage, datastore_path):\n    if jq_support:\n        check_json_ext_filter('jq:.[] | select(.status | contains(\"Sold\"))', client, live_server, datastore_path=datastore_path)\n    delete_all_watches(client)\n\ndef test_check_jqraw_ext_filter(client, live_server, measure_memory_usage, datastore_path):\n    if jq_support:\n        check_json_ext_filter('jq:.[] | select(.status | contains(\"Sold\"))', client, live_server, datastore_path=datastore_path)\n    delete_all_watches(client)\n\ndef test_jsonpath_BOM_utf8(client, live_server, measure_memory_usage, datastore_path):\n    from .. import html_tools\n\n    # JSON string with BOM and correct double-quoted keys\n    json_str = '\\ufeff{\"name\": \"José\", \"emoji\": \"😊\", \"language\": \"中文\", \"greeting\": \"Привет\"}'\n\n    # See that we can find the second <script> one, which is not broken, and matches our filter\n    text = html_tools.extract_json_as_string(json_str, \"json:$.name\")\n    assert text == '\"José\"'\n    delete_all_watches(client)\n\n    \n"
  },
  {
    "path": "changedetectionio/tests/test_live_preview.py",
    "content": "#!/usr/bin/env python3\n\nfrom flask import url_for\nfrom changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client, delete_all_watches\nimport os\n\n\ndef set_response(datastore_path):\n\n    data = \"\"\"<html>\n       <body>Awesome, you made it<br>\nyeah the socks request worked<br>\nsomething to ignore<br>\nsomething to trigger<br>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(data)\n\ndef test_content_filter_live_preview(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n    set_response(datastore_path=datastore_path)\n    import time\n    test_url = url_for('test_endpoint', _external=True)\n\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    time.sleep(0.5)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\n            \"include_filters\": \"\",\n            \"fetch_backend\": 'html_requests',\n            \"ignore_text\": \"something to ignore\",\n            \"trigger_text\": \"something to trigger\",\n            \"url\": test_url,\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    # The endpoint is a POST and accepts the form values to override the watch preview\n    import json\n\n    # DEFAULT OUTPUT WITHOUT ANYTHING UPDATED/CHANGED - SHOULD SEE THE WATCH DEFAULTS\n    res = client.post(\n        url_for(\"ui.ui_edit.watch_get_preview_rendered\", uuid=uuid)\n    )\n    default_return = json.loads(res.data.decode('utf-8'))\n    assert default_return.get('after_filter')\n    assert default_return.get('before_filter')\n    assert default_return.get('ignore_line_numbers') == [3] # \"something to ignore\" line 3\n    assert default_return.get('trigger_line_numbers') == [4] # \"something to trigger\" line 4\n\n    # SEND AN UPDATE AND WE SHOULD SEE THE OUTPUT CHANGE SO WE KNOW TO HIGHLIGHT NEW STUFF\n    res = client.post(\n        url_for(\"ui.ui_edit.watch_get_preview_rendered\", uuid=uuid),\n        data={\n            \"include_filters\": \"\",\n            \"fetch_backend\": 'html_requests',\n            \"ignore_text\": \"sOckS\", # Also be sure case insensitive works\n            \"trigger_text\": \"AweSOme\",\n            \"url\": test_url,\n        },\n    )\n    reply = json.loads(res.data.decode('utf-8'))\n    assert reply.get('after_filter')\n    assert reply.get('before_filter')\n    assert reply.get('ignore_line_numbers') == [2]  # Ignored - \"socks\" on line 2\n    assert reply.get('trigger_line_numbers') == [1]  # Triggers \"Awesome\" in line 1\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_nonrenderable_pages.py",
    "content": "#!/usr/bin/env python3\n\nfrom flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, delete_all_watches\nimport time\nimport os\n\n\ndef set_nonrenderable_response(datastore_path):\n    test_return_data = \"\"\"<html>\n    <head><title>modified head title</title></head>\n    <!-- like when some angular app was broken and doesnt render or whatever -->\n    <body>\n     </body>\n     </html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    time.sleep(1)\n\n    return None\n\ndef set_zero_byte_response(datastore_path):\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\")\n    time.sleep(1)\n    return None\n\ndef test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n\n    #####################\n    client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-empty_pages_are_a_change\": \"\", # default, OFF, they are NOT a change\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    # this should not trigger a change, because no good text could be converted from the HTML\n    set_nonrenderable_response(datastore_path)\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n\n    assert watch.last_changed == 0\n    assert watch['last_checked'] != 0\n\n\n\n\n    # ok now do the opposite\n\n    client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-empty_pages_are_a_change\": \"y\",\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    set_modified_response(datastore_path=datastore_path)\n\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n    client.get(url_for(\"ui.mark_all_viewed\"), follow_redirects=True)\n    time.sleep(0.2)\n\n\n    # A totally zero byte (#2528) response should also not trigger an error\n    set_zero_byte_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    # 2877\n    assert watch.last_changed == watch['last_checked']\n\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data # A change should have registered because empty_pages_are_a_change is ON\n    assert b'fetch-error' not in res.data\n\n    #\n    # Cleanup everything\n    delete_all_watches(client)\n\n"
  },
  {
    "path": "changedetectionio/tests/test_notification.py",
    "content": "import json\nimport os\nimport time\nimport re\nfrom flask import url_for\nfrom loguru import logger\n\nfrom .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output\nfrom . util import  extract_UUID_from_client\nimport logging\nimport base64\n\nfrom changedetectionio.notification import (\n    default_notification_body,\n    default_notification_format,\n    default_notification_title, valid_notification_formats\n)\nfrom ..diff import HTML_CHANGED_STYLE\nfrom ..model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\nfrom ..notification_service import FormattableTimestamp\n\n\n# Hard to just add more live server URLs when one test is already running (I think)\n# So we add our test here (was in a different file)\ndef test_check_notification(client, live_server, measure_memory_usage, datastore_path):\n    \n    set_original_response(datastore_path=datastore_path)\n\n    # Re 360 - new install should have defaults set\n    res = client.get(url_for(\"settings.settings_page\"))\n    notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')+\"?status_code=204\"\n\n    assert default_notification_body.encode() in res.data\n    assert default_notification_title.encode() in res.data\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"fallback-title \"+default_notification_title,\n              \"application-notification_body\": \"fallback-body \"+default_notification_body,\n              \"application-notification_format\": default_notification_format,\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    res = client.get(url_for(\"settings.settings_page\"))\n    for k,v in valid_notification_formats.items():\n        if k == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:\n            continue\n        assert f'value=\"{k}\"'.encode() in res.data # Should be by key NOT value\n        assert f'value=\"{v}\"'.encode() not in res.data # Should be by key NOT value\n\n\n    # When test mode is in BASE_URL env mode, we should see this already configured\n    env_base_url = os.getenv('BASE_URL', '').strip()\n    if len(env_base_url):\n        logging.debug(\">>> BASE_URL enabled, looking for %s\", env_base_url)\n        res = client.get(url_for(\"settings.settings_page\"))\n        assert bytes(env_base_url.encode('utf-8')) in res.data\n    else:\n        logging.debug(\">>> SKIPPING BASE_URL check\")\n\n    # re #242 - when you edited an existing new entry, it would not correctly show the notification settings\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": ''},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    # Give the thread time to pick up the first version\n    wait_for_all_checks(client)\n\n    # We write the PNG to disk, but a JPEG should appear in the notification\n    # Write the last screenshot png\n    testimage_png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='\n\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    screenshot_dir = os.path.join(datastore_path, str(uuid))\n    os.makedirs(screenshot_dir, exist_ok=True)\n    with open(os.path.join(screenshot_dir, 'last-screenshot.png'), 'wb') as f:\n        f.write(base64.b64decode(testimage_png))\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n\n    print (\">>>> Notification URL: \"+notification_url)\n\n    notification_form_data = {\"notification_urls\": notification_url,\n                              \"notification_title\": \"New ChangeDetection.io Notification - {{watch_url}}\",\n                              \"notification_body\": \"BASE URL: {{base_url}}\\n\"\n                                                   \"Watch URL: {{watch_url}}\\n\"\n                                                   \"Watch UUID: {{watch_uuid}}\\n\"\n                                                   \"Watch title: {{watch_title}}\\n\"\n                                                   \"Watch tag: {{watch_tag}}\\n\"\n                                                   \"Preview: {{preview_url}}\\n\"\n                                                   \"Diff URL: {{diff_url}}\\n\"\n                                                   \"Snapshot: {{current_snapshot}}\\n\"\n                                                   \"Diff: {{diff}}\\n\"\n                                                   \"Diff Added: {{diff_added}}\\n\"\n                                                   \"Diff Removed: {{diff_removed}}\\n\"\n                                                   \"Diff Full: {{diff_full}}\\n\"\n                                                   \"Diff with args: {{diff(context=3)}}\"\n                                                   \"Diff as Patch: {{diff_patch}}\\n\"\n                                                   \"Change datetime: {{change_datetime}}\\n\"\n                                                   \"Change datetime format: Weekday {{change_datetime(format='%A')}}\\n\"\n                                                   \"Change datetime format: {{change_datetime(format='%Y-%m-%dT%H:%M:%S%z')}}\\n\"\n                                                   \":-)\",\n                              \"notification_screenshot\": True,\n                              \"notification_format\": 'text'}\n\n    notification_form_data.update({\n        \"url\": test_url,\n        \"tags\": \"my tag, my second tag\",\n        \"title\": \"my title\",\n        \"headers\": \"\",\n        \"fetch_backend\": \"html_requests\",\n        \"time_between_check_use_default\": \"y\"})\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data=notification_form_data,\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n\n    # Hit the edit page, be sure that we saved it\n    # Re #242 - wasnt saving?\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"))\n    assert bytes(notification_url.encode('utf-8')) in res.data\n    assert bytes(\"New ChangeDetection.io Notification\".encode('utf-8')) in res.data\n\n    ## Now recheck, and it should have sent the notification\n    wait_for_all_checks(client)\n    set_modified_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n\n    # Check no errors were recorded\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'notification-error' not in res.data\n\n\n    # Verify what was sent as a notification, this file should exist\n    with open(os.path.join(datastore_path, \"notification.txt\"), \"r\") as f:\n        notification_submission = f.read()\n    os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n\n    # Did we see the URL that had a change, in the notification?\n    # Diff was correctly executed\n\n    assert \"Diff Full: Some initial text\" in notification_submission\n    assert \"Diff: (changed) Which is across multiple lines\" in notification_submission\n    assert \"(into) which has this one new line\" in notification_submission\n    # Re #342 - check for accidental python byte encoding of non-utf8/string\n    assert \"b'\" not in notification_submission\n    assert re.search('Watch UUID: [0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}', notification_submission, re.IGNORECASE)\n    assert \"Watch title: my title\" in notification_submission\n    assert \"Watch tag: my tag, my second tag\" in notification_submission\n    assert \"diff/\" in notification_submission\n    assert \"preview/\" in notification_submission\n    assert \":-)\" in notification_submission\n    assert \"New ChangeDetection.io Notification - {}\".format(test_url) in notification_submission\n    assert test_url in notification_submission\n\n    assert ':-)' in notification_submission\n    # Check the attachment was added, and that it is a JPEG from the original PNG\n    notification_submission_object = json.loads(notification_submission)\n    assert notification_submission_object\n\n    import time\n    # Could be from a few seconds ago (when the notification was fired vs in this test checking), so check for any\n    times_possible = [str(FormattableTimestamp(int(time.time()) - i)) for i in range(15)]\n    assert any(t in notification_submission for t in times_possible)\n\n    txt = f\"Weekday {FormattableTimestamp(int(time.time()))(format='%A')}\"\n    assert txt in notification_submission\n\n\n\n\n    # We keep PNG screenshots for now\n    # IF THIS FAILS YOU SHOULD BE TESTING WITH ENV VAR REMOVE_REQUESTS_OLD_SCREENSHOTS=False\n    assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.png'\n    assert len(notification_submission_object['attachments'][0]['base64'])\n    assert notification_submission_object['attachments'][0]['mimetype'] == 'image/png'\n    jpeg_in_attachment = base64.b64decode(notification_submission_object['attachments'][0]['base64'])\n\n    # Assert that the JPEG is readable (didn't get chewed up somewhere)\n    from PIL import Image\n    import io\n    assert Image.open(io.BytesIO(jpeg_in_attachment))\n\n    if env_base_url:\n        # Re #65 - did we see our BASE_URl ?\n        logging.debug (\">>> BASE_URL checking in notification: %s\", env_base_url)\n        assert env_base_url in notification_submission\n    else:\n        logging.debug(\">>> Skipping BASE_URL check\")\n\n\n    # This should insert the {current_snapshot}\n    set_more_modified_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n    # Verify what was sent as a notification, this file should exist\n    with open(os.path.join(datastore_path, \"notification.txt\"), \"r\") as f:\n        notification_submission = f.read()\n    assert \"Ohh yeah awesome\" in notification_submission\n\n\n    # Prove that \"content constantly being marked as Changed with no Updating causes notification\" is not a thing\n    # https://github.com/dgtlmoon/changedetection.io/discussions/192\n    os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    assert os.path.exists(os.path.join(datastore_path, \"notification.txt\")) == False\n\n    res = client.get(url_for(\"settings.notification_logs\"))\n    # be sure we see it in the output log\n    assert b'New ChangeDetection.io Notification - ' + test_url.encode('utf-8') in res.data\n\n    set_original_response(datastore_path=datastore_path)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n        \"url\": test_url,\n        \"tags\": \"my tag\",\n        \"title\": \"my title\",\n        \"notification_urls\": '',\n        \"notification_title\": '',\n        \"notification_body\": '',\n        \"notification_format\": default_notification_format,\n        \"fetch_backend\": \"html_requests\",\n        \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    wait_for_all_checks(client)\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n\n    # Verify what was sent as a notification, this file should exist\n    with open(os.path.join(datastore_path, \"notification.txt\"), \"r\") as f:\n        notification_submission = f.read()\n    assert \"fallback-title\" in notification_submission\n    assert \"fallback-body\" in notification_submission\n\n    # cleanup for the next\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n\n\ndef test_notification_urls_jinja2_apprise_integration(client, live_server, measure_memory_usage, datastore_path):\n\n    #\n    # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation\n    test_notification_url = \"hassio://127.0.0.1/longaccesstoken?verify=no&nid={{watch_uuid}}\"\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n              \"application-fetch_backend\": \"html_requests\",\n              \"application-minutes_between_check\": 180,\n              \"application-notification_body\": '{ \"url\" : \"{{ watch_url }}\", \"secret\": 444, \"somebug\": \"网站监测 内容更新了\", \"another\": \"{{diff|truncate(1500)}}\" }',\n              \"application-notification_format\": default_notification_format,\n              \"application-notification_urls\": test_notification_url,\n              # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation\n              \"application-notification_title\": \"New ChangeDetection.io Notification - {{ watch_url }}  {{diff|truncate(200)}} \",\n              },\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n    assert '网站监测'.encode() in res.data\n    assert b'{{diff|truncate(1500)}}' in res.data\n    assert b'{{diff|truncate(200)}}' in res.data\n\n\n\n\ndef test_notification_custom_endpoint_and_jinja2(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    # test_endpoint - that sends the contents of a file\n    # test_notification_endpoint - that takes a POST and writes it to file (test-datastore/notification.txt)\n\n    # CUSTOM JSON BODY CHECK for POST://\n    set_original_response(datastore_path=datastore_path)\n    # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation\n    test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+\"?status_code=204&watch_uuid={{ watch_uuid }}&xxx={{ watch_url }}&now={% now 'Europe/London', '%Y-%m-%d' %}&+custom-header=123&+second=hello+world%20%22space%22\"\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n              \"application-fetch_backend\": \"html_requests\",\n              \"application-minutes_between_check\": 180,\n              \"application-notification_body\": '{ \"url\" : \"{{ watch_url }}\", \"secret\": 444, \"somebug\": \"网站监测 内容更新了\" }',\n              \"application-notification_format\": default_notification_format,\n              \"application-notification_urls\": test_notification_url,\n              # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation\n              \"application-notification_title\": \"New ChangeDetection.io Notification - {{ watch_url }} \",\n              },\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n    # Add a watch and trigger a HTTP POST\n    test_url = url_for('test_endpoint', _external=True)\n    watch_uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, tag=\"nice one\")\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n    set_modified_response(datastore_path=datastore_path)\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n\n\n    # Check no errors were recorded, because we asked for 204 which is slightly uncommon but is still OK\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'notification-error' not in res.data\n\n    with open(os.path.join(datastore_path, \"notification.txt\"), 'r') as f:\n        x = f.read()\n        j = json.loads(x)\n        assert j['url'].startswith('http://localhost')\n        assert j['secret'] == 444\n        assert j['somebug'] == '网站监测 内容更新了'\n\n\n    # URL check, this will always be converted to lowercase\n    assert os.path.isfile(os.path.join(datastore_path, \"notification-url.txt\"))\n    with open(os.path.join(datastore_path, \"notification-url.txt\"), 'r') as f:\n        notification_url = f.read()\n        assert 'xxx=http' in notification_url\n        # apprise style headers should be stripped\n        assert 'custom-header' not in notification_url\n        # Check jinja2 custom arrow/jinja2-time replace worked\n        assert 'now=2' in notification_url\n        # Check our watch_uuid appeared\n        assert f'watch_uuid={watch_uuid}' in notification_url\n\n\n    with open(os.path.join(datastore_path, \"notification-headers.txt\"), 'r') as f:\n        notification_headers = f.read()\n        assert 'custom-header: 123' in notification_headers.lower()\n        assert 'second: hello world \"space\"' in notification_headers.lower()\n\n\n    # Should always be automatically detected as JSON content type even when we set it as 'Plain Text' (default)\n    assert os.path.isfile(os.path.join(datastore_path, \"notification-content-type.txt\"))\n    with open(os.path.join(datastore_path, \"notification-content-type.txt\"), 'r') as f:\n        assert 'application/json' in f.read()\n\n    os.unlink(os.path.join(datastore_path, \"notification-url.txt\"))\n\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n\n\n#2510\n#@todo run it again as text, html, htmlcolor\ndef test_global_send_test_notification(client, live_server, measure_memory_usage, datastore_path):\n\n    set_original_response(datastore_path=datastore_path)\n    if os.path.isfile(os.path.join(datastore_path, \"notification.txt\")):\n        os.unlink(os.path.join(datastore_path, \"notification.txt\")) \\\n\n    # 1995 UTF-8 content should be encoded\n    test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}'\n\n    # otherwise other settings would have already existed from previous tests in this file\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"application-fetch_backend\": \"html_requests\",\n            \"application-minutes_between_check\": 180,\n            \"application-notification_body\": test_body,\n            \"application-notification_format\": default_notification_format,\n            \"application-notification_urls\": \"\",\n            \"application-notification_title\": \"New ChangeDetection.io Notification - {{ watch_url }}\",\n        },\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'nice one'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+\"?xxx={{ watch_url }}&+custom-header=123\"\n\n    ######### Test global/system settings\n    res = client.post(\n        url_for(\"ui.ui_notification.ajax_callback_send_notification_test\")+\"?mode=global-settings\",\n        data={\"notification_urls\": test_notification_url},\n        follow_redirects=True\n    )\n\n    assert res.status_code != 400\n    assert res.status_code != 500\n\n    with open(os.path.join(datastore_path, \"notification.txt\"), 'r') as f:\n        x = f.read()\n        assert 'change detection is cool 网站监测 内容更新了' in x\n        if 'html' in default_notification_format:\n            # this should come from default text when in global/system mode here changedetectionio/notification_service.py\n            assert 'title=\"Changed into\">Example text:' in x\n        else:\n            assert 'title=\"Changed into\">Example text:' not in x\n            assert 'span' not in x\n            assert 'Example text:' in x\n\n    os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n\n    ######### Test group/tag settings\n    res = client.post(\n        url_for(\"ui.ui_notification.ajax_callback_send_notification_test\")+\"?mode=group-settings\",\n        data={\"notification_urls\": test_notification_url},\n        follow_redirects=True\n    )\n\n    assert res.status_code != 400\n    assert res.status_code != 500\n\n    # Give apprise time to fire\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n\n    with open(os.path.join(datastore_path, \"notification.txt\"), 'r') as f:\n        x = f.read()\n        # Should come from notification.py default handler when there is no notification body to pull from\n        assert 'change detection is cool 网站监测 内容更新了' in x\n\n    ## Check that 'test' catches errors\n    test_notification_url = 'post://akjsdfkjasdkfjasdkfjasdkjfas232323/should-error'\n\n    ######### Test global/system settings\n    res = client.post(\n        url_for(\"ui.ui_notification.ajax_callback_send_notification_test\")+\"?mode=global-settings\",\n        data={\"notification_urls\": test_notification_url},\n        follow_redirects=True\n    )\n    assert res.status_code == 400\n    assert (\n        b\"No address found\" in res.data or\n        b\"Name or service not known\" in res.data or\n        b\"nodename nor servname provided\" in res.data or\n        b\"Temporary failure in name resolution\" in res.data or\n        b\"Failed to establish a new connection\" in res.data or\n        b\"Connection error occurred\" in res.data\n    )\n    \n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n\n    ######### Test global/system settings - When everything is deleted it should give a helpful error\n    # See #2727\n    res = client.post(\n        url_for(\"ui.ui_notification.ajax_callback_send_notification_test\")+\"?mode=global-settings\",\n        data={\"notification_urls\": test_notification_url},\n        follow_redirects=True\n    )\n    assert res.status_code == 400\n    assert b\"Error: You must have atleast one watch configured for 'test notification' to work\" in res.data\n\n\n#2510\ndef test_single_send_test_notification_on_watch(client, live_server, measure_memory_usage, datastore_path):\n\n    set_original_response(datastore_path=datastore_path)\n    if os.path.isfile(os.path.join(datastore_path, \"notification.txt\")):\n        os.unlink(os.path.join(datastore_path, \"notification.txt\")) \\\n\n\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+\"?xxx={{ watch_url }}&+custom-header=123\"\n    # 1995 UTF-8 content should be encoded\n    test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}\\n\\nCurrent snapshot: {{current_snapshot}}'\n    ######### Test global/system settings\n    res = client.post(\n        url_for(\"ui.ui_notification.ajax_callback_send_notification_test\")+f\"/{uuid}\",\n        data={\"notification_urls\": test_notification_url,\n              \"notification_body\": test_body,\n              \"notification_format\": default_notification_format,\n              \"notification_title\": \"New ChangeDetection.io Notification - {{ watch_url }}\",\n              },\n        follow_redirects=True\n    )\n\n    assert res.status_code != 400\n    assert res.status_code != 500\n\n    with open(os.path.join(datastore_path, \"notification.txt\"), 'r') as f:\n        x = f.read()\n        assert 'change detection is cool 网站监测 内容更新了' in x\n        if 'html' in default_notification_format:\n            # this should come from default text when in global/system mode here changedetectionio/notification_service.py\n            assert 'title=\"Changed into\">Example text:' in x\n        else:\n            assert 'title=\"Changed into\">Example text:' not in x\n            assert 'span' not in x\n            assert 'Example text:' in x\n        #3720 current_snapshot check, was working but lets test it exactly.\n        assert 'Current snapshot: Example text: example test' in x\n    os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n\ndef _test_color_notifications(client, notification_body_token, datastore_path):\n\n    set_original_response(datastore_path=datastore_path)\n\n    if os.path.isfile(os.path.join(datastore_path, \"notification.txt\")):\n        os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n\n\n    test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+\"?xxx={{ watch_url }}&+custom-header=123\"\n\n\n    # otherwise other settings would have already existed from previous tests in this file\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"application-fetch_backend\": \"html_requests\",\n            \"application-minutes_between_check\": 180,\n            \"application-notification_body\": notification_body_token,\n            \"application-notification_format\": \"htmlcolor\",\n            \"application-notification_urls\": test_notification_url,\n            \"application-notification_title\": \"New ChangeDetection.io Notification - {{ watch_url }}\",\n        },\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'nice one'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added\" in res.data\n\n    wait_for_all_checks(client)\n\n    set_modified_response(datastore_path=datastore_path)\n\n\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n\n    with open(os.path.join(datastore_path, \"notification.txt\"), 'r') as f:\n        x = f.read()\n        s = f'<span style=\"{HTML_CHANGED_STYLE}\" role=\"note\" aria-label=\"Changed text\" title=\"Changed text\">Which is across multiple lines</span><br>'\n        assert s in x\n\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n\n# Just checks the format of the colour notifications was correct\ndef test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):\n    _test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)\n    _test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)\n\n"
  },
  {
    "path": "changedetectionio/tests/test_notification_errors.py",
    "content": "import os\nimport time\nfrom flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, delete_all_watches\nimport logging\n\ndef test_check_notification_error_handling(client, live_server, measure_memory_usage, datastore_path):\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n    set_original_response(datastore_path=datastore_path)\n\n    # Set a URL and fetch it, then set a notification URL which is going to give errors\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": ''},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    wait_for_all_checks(client)\n    set_modified_response(datastore_path=datastore_path)\n\n    working_notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')\n    broken_notification_url = \"jsons://broken-url-xxxxxxxx123/test\"\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        # A URL with errors should not block the one that is working\n        data={\"notification_urls\": f\"{broken_notification_url}\\r\\n{working_notification_url}\",\n              \"notification_title\": \"xxx\",\n              \"notification_body\": \"xxxxx\",\n              \"notification_format\": 'text',\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"title\": \"\",\n              \"headers\": \"\",\n              \"time_between_check-minutes\": \"180\",\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n\n    wait_for_all_checks(client)\n\n    found=False\n    for i in range(1, 10):\n\n        logging.debug(\"Fetching watch overview....\")\n        res = client.get(\n            url_for(\"watchlist.index\"))\n\n        if bytes(\"Notification error detected\".encode('utf-8')) in res.data:\n            found=True\n            break\n\n        time.sleep(1)\n\n    assert found\n\n\n    # The error should show in the notification logs\n    res = client.get(\n        url_for(\"settings.notification_logs\"))\n    # Check for various DNS/connection error patterns that may appear in different environments\n    found_name_resolution_error = (\n        b\"No address found\" in res.data or \n        b\"Name or service not known\" in res.data or\n        b\"nodename nor servname provided\" in res.data or\n        b\"Temporary failure in name resolution\" in res.data or\n        b\"Failed to establish a new connection\" in res.data or\n        b\"Connection error occurred\" in res.data\n    )\n    assert found_name_resolution_error\n\n    # And the working one, which is after the 'broken' one should still have fired\n    with open(os.path.join(datastore_path, \"notification.txt\"), \"r\") as f:\n        notification_submission = f.read()\n    os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n    assert 'xxxxx' in notification_submission\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_obfuscations.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup\nimport os\n\n\ndef set_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     <span>The price is</span><span>$<!-- -->90<!-- -->.<!-- -->74</span>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef test_obfuscations(client, live_server, measure_memory_usage, datastore_path):\n    set_original_ignore_response(datastore_path)\n   #  live_server_setup(live_server) # Setup on conftest per function\n    time.sleep(1)\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    time.sleep(3)\n\n    # Check HTML conversion detected and workd\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b'$90.74' in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_pdf.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks\nimport os\n\n\n# `subtractive_selectors` should still work in `source:` type requests\ndef test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):\n    import shutil\n    import os\n\n    shutil.copy(\"tests/test.pdf\", os.path.join(datastore_path, \"endpoint-test.pdf\"))\n    first_version_size = os.path.getsize(os.path.join(datastore_path, \"endpoint-test.pdf\"))\n\n    test_url = url_for('test_pdf_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    dates = list(watch.history.keys())\n    snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])\n\n    # PDF header should not be there (it was converted to text)\n    assert 'PDF' not in snapshot_contents\n    # Was converted away from HTML\n    assert 'pdftohtml' not in snapshot_contents.lower() # Generator tag shouldnt be there\n    assert f'Original file size - {first_version_size}' in snapshot_contents\n    assert 'html' not in snapshot_contents.lower() # is converted from html\n    assert 'body' not in snapshot_contents.lower()  # is converted from html\n    # And our text content was there\n    assert 'hello world' in snapshot_contents\n\n    # So we know if the file changes in other ways\n    import hashlib\n    original_md5 = hashlib.md5(open(os.path.join(datastore_path, \"endpoint-test.pdf\"), 'rb').read()).hexdigest().upper()\n    # We should have one\n    assert len(original_md5) >0\n    # And it's going to be in the document\n    assert f'Document checksum - {original_md5}' in snapshot_contents\n\n    shutil.copy(\"tests/test2.pdf\", os.path.join(datastore_path, \"endpoint-test.pdf\"))\n    changed_md5 = hashlib.md5(open(os.path.join(datastore_path, \"endpoint-test.pdf\"), 'rb').read()).hexdigest().upper()\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n\n    # Now something should be ready, indicated by having a 'has-unread-changes' class\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    # The original checksum should be not be here anymore (cdio adds it to the bottom of the text)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert original_md5.encode('utf-8') not in res.data\n    assert changed_md5.encode('utf-8') in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert original_md5.encode('utf-8') in res.data\n    assert changed_md5.encode('utf-8') in res.data\n    assert b'here is a change' in res.data\n\n\n    dates = list(watch.history.keys())\n    # new snapshot was also OK, no HTML\n    snapshot_contents = watch.get_history_snapshot(timestamp=dates[1])\n    assert 'html' not in snapshot_contents.lower()\n    assert f'Original file size - {os.path.getsize(os.path.join(datastore_path, \"endpoint-test.pdf\"))}' in snapshot_contents\n    assert f'here is a change' in snapshot_contents\n    assert os.path.getsize(os.path.join(datastore_path, \"endpoint-test.pdf\")) != first_version_size # And the disk change worked\n\n\n    "
  },
  {
    "path": "changedetectionio/tests/test_preview_endpoints.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks\nimport os\n\n\n# `subtractive_selectors` should still work in `source:` type requests\ndef test_fetch_pdf(client, live_server, measure_memory_usage, datastore_path):\n    import shutil\n    shutil.copy(\"tests/test.pdf\", os.path.join(datastore_path, \"endpoint-test.pdf\"))\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n    test_url = url_for('test_pdf_endpoint', _external=True)\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # PDF header should not be there (it was converted to text)\n    assert b'PDF' not in res.data[:10]\n    assert b'hello world' in res.data\n\n    # So we know if the file changes in other ways\n    import hashlib\n    original_md5 = hashlib.md5(open(os.path.join(datastore_path, \"endpoint-test.pdf\"), 'rb').read()).hexdigest().upper()\n    # We should have one\n    assert len(original_md5) > 0\n    # And it's going to be in the document\n    assert b'Document checksum - ' + bytes(str(original_md5).encode('utf-8')) in res.data\n\n    shutil.copy(\"tests/test2.pdf\", os.path.join(datastore_path, \"endpoint-test.pdf\"))\n    changed_md5 = hashlib.md5(open(os.path.join(datastore_path, \"endpoint-test.pdf\"), 'rb').read()).hexdigest().upper()\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n\n    # Now something should be ready, indicated by having a 'has-unread-changes' class\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    # The original checksum should be not be here anymore (cdio adds it to the bottom of the text)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert original_md5.encode('utf-8') not in res.data\n    assert changed_md5.encode('utf-8') in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert original_md5.encode('utf-8') in res.data\n    assert changed_md5.encode('utf-8') in res.data\n\n    assert b'here is a change' in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_queue_handler.py",
    "content": "import os\nimport time\nfrom flask import url_for\nfrom .util import set_original_response,  wait_for_all_checks, wait_for_notification_endpoint_output\nfrom ..notification import valid_notification_formats\nfrom loguru import  logger\n\ndef test_queue_system(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Test that multiple workers can process queue concurrently without blocking each other\"\"\"\n    # (pytest) Werkzeug's threaded server uses ThreadPoolExecutor with a default limit of around 40 threads (or min(32, os.cpu_count() + 4)).\n    items = os.cpu_count() +3\n    delay = 10\n    # Auto-queue is off here.\n    live_server.app.config['DATASTORE'].data['settings']['application']['all_paused'] = True\n\n    test_urls = [\n        f\"{url_for('test_endpoint', _external=True)}?delay={delay}&id={i}&content=hello+test+content+{i}\"\n        for i in range(0, items)\n    ]\n\n    # Import 30 URLs to queue\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": \"\\r\\n\".join(test_urls)},\n        follow_redirects=True\n    )\n    assert f\"{items} Imported\".encode('utf-8') in res.data\n\n    client.application.set_workers(items)\n\n    start = time.time()\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    time.sleep(delay/2)\n\n    # Verify all workers are idle (no UUIDs being processed)\n    from changedetectionio import worker_pool\n    running_uuids = worker_pool.get_running_uuids()\n    logger.debug( f\"Should be atleast some workers running - {len(running_uuids)} UUIDs still being processed: {running_uuids}\")\n    assert len(running_uuids) != 0, f\"Should be atleast some workers running - {len(running_uuids)} UUIDs still being processed: {running_uuids}\"\n\n    wait_for_all_checks(client)\n\n    # all workers should be done in less than say 10 seconds (they take time to 'see' something is in the queue too)\n    total_time = (time.time() - start)\n    logger.debug(f\"All workers finished {items} items in less than {delay} seconds per job. {total_time}s total\")\n    # if there was a bug in queue handler not running parallel, this would blow out to items*delay seconds\n    assert total_time < delay + 10, f\"All workers finished {items} items in less than {delay} seconds per job, total time {total_time}s\"\n\n    # Verify all workers are idle (no UUIDs being processed)\n    from changedetectionio import worker_pool\n    running_uuids = worker_pool.get_running_uuids()\n    assert len(running_uuids) == 0, f\"Expected all workers to be idle, but {len(running_uuids)} UUIDs still being processed: {running_uuids}\"\n"
  },
  {
    "path": "changedetectionio/tests/test_request.py",
    "content": "import json\nimport os\nimport time\nfrom flask import url_for\nfrom . util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_UUID_from_client, delete_all_watches\n\n\n\n# Hard to just add more live server URLs when one test is already running (I think)\n# So we add our test here (was in a different file)\ndef test_headers_in_request(client, live_server, measure_memory_usage, datastore_path):\n    #ve_server_setup(live_server)\n    # Add our URL to the import page\n    test_url = url_for('test_headers', _external=True)\n    if os.getenv('PLAYWRIGHT_DRIVER_URL'):\n        # Because its no longer calling back to localhost but from the browser container, set in test-only.yml\n        test_url = test_url.replace('localhost', 'changedet')\n\n    # Add the test URL twice, we will check\n    uuidA = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    uuidB = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n    cookie_header = '_ga=GA1.2.1022228332; cookie-preferences=analytics:accepted;'\n\n\n    # Add some headers to a request\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuidA),\n        data={\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"fetch_backend\": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',\n              \"headers\": \"jinja2:{{ 1+1 }}\\nxxx:ooo\\ncool:yeah\\r\\ncookie:\"+cookie_header,\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick up the first version\n    wait_for_all_checks(client)\n\n    # The service should echo back the request headers\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuidA),\n        follow_redirects=True\n    )\n\n    # Flask will convert the header key to uppercase\n    assert b\"Jinja2:2\" in res.data\n    assert b\"Xxx:ooo\" in res.data\n    assert b\"Cool:yeah\" in res.data\n\n    # The test call service will return the headers as the body\n    from html import escape\n    assert escape(cookie_header).encode('utf-8') in res.data\n\n    wait_for_all_checks(client)\n\n    # Re #137 -  It should have only one set of headers entered\n    watches_with_headers = 0\n    for k, watch in client.application.config.get('DATASTORE').data.get('watching').items():\n            if (len(watch['headers'])):\n                watches_with_headers += 1\n    assert watches_with_headers == 1\n\n    # 'server' http header was automatically recorded\n    for k, watch in client.application.config.get('DATASTORE').data.get('watching').items():\n        assert 'custom' in watch.get('remote_server_reply') # added in util.py\n\n    delete_all_watches(client)\n\ndef test_body_in_request(client, live_server, measure_memory_usage, datastore_path):\n    import os\n\n    # Add our URL to the import page\n    test_url = url_for('test_body', _external=True)\n    if os.getenv('PLAYWRIGHT_DRIVER_URL'):\n        # Because its no longer calling back to localhost but from the browser container, set in test-only.yml\n        test_url = test_url.replace('localhost', 'cdio')\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # add the first 'version'\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"method\": \"POST\",\n              \"fetch_backend\": \"html_requests\",\n              \"body\": \"something something\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    wait_for_all_checks(client)\n\n    # Now the change which should trigger a change\n    body_value = 'Test Body Value {{ 1+1 }}'\n    body_value_formatted = 'Test Body Value 2'\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"method\": \"POST\",\n              \"fetch_backend\": \"html_requests\",\n              \"body\": body_value,\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    wait_for_all_checks(client)\n\n    # The service should echo back the body\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuid),\n        follow_redirects=True\n    )\n\n    # If this gets stuck something is wrong, something should always be there\n    assert b\"No history found\" not in res.data\n    # We should see the formatted value of what we sent in the reply\n    assert str.encode(body_value) not in res.data\n    assert str.encode(body_value_formatted) in res.data\n\n    ####### data sanity checks\n    # Add the test URL twice, we will check\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    watches_with_body = 0\n\n    # Read individual watch.json files\n    for uuid in client.application.config.get('DATASTORE').data['watching'].keys():\n        watch_json_file = os.path.join(datastore_path, uuid, 'watch.json')\n        assert os.path.exists(watch_json_file), f\"watch.json should exist at {watch_json_file}\"\n        with open(watch_json_file, 'r', encoding='utf-8') as f:\n            watch_data = json.load(f)\n            if watch_data.get('body') == body_value:\n                watches_with_body += 1\n\n    # Should be only one with body set\n    assert watches_with_body==1\n\n    # Attempt to add a body with a GET method\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"method\": \"GET\",\n              \"fetch_backend\": \"html_requests\",\n              \"body\": \"invalid\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Body must be empty when Request Method is set to GET\" in res.data\n    delete_all_watches(client)\n\ndef test_method_in_request(client, live_server, measure_memory_usage, datastore_path):\n    import os\n    # Add our URL to the import page\n    test_url = url_for('test_method', _external=True)\n    if os.getenv('PLAYWRIGHT_DRIVER_URL'):\n        # Because its no longer calling back to localhost but from the browser container, set in test-only.yml\n        test_url = test_url.replace('localhost', 'cdio')\n\n    # Add the test URL twice, we will check\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # Attempt to add a method which is not valid\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            \"tags\": \"\",\n            \"fetch_backend\": \"html_requests\",\n            \"method\": \"invalid\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Not a valid choice\" in res.data\n\n    # Add a properly formatted body\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            \"tags\": \"\",\n            \"fetch_backend\": \"html_requests\",\n            \"method\": \"PATCH\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # Give the thread time to pick up the first version\n    wait_for_all_checks(client)\n\n    # The service should echo back the request verb\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # The test call service will return the verb as the body\n    assert b\"PATCH\" in res.data\n\n    wait_for_all_checks(client)\n\n    watches_with_method = 0\n\n    # Read individual watch.json files\n    for uuid in client.application.config.get('DATASTORE').data['watching'].keys():\n        watch_json_file = os.path.join(datastore_path, uuid, 'watch.json')\n        assert os.path.exists(watch_json_file), f\"watch.json should exist at {watch_json_file}\"\n        with open(watch_json_file, 'r', encoding='utf-8') as f:\n            watch_data = json.load(f)\n            if watch_data.get('method') == 'PATCH':\n                watches_with_method += 1\n\n    # Should be only one with method set to PATCH\n    assert watches_with_method == 1\n\n    delete_all_watches(client)\n\n# Re #2408 - user-agent override test, also should handle case-insensitive header deduplication\ndef test_ua_global_override(client, live_server, measure_memory_usage, datastore_path):\n    ##  live_server_setup(live_server) # Setup on conftest per function\n    test_url = url_for('test_headers', _external=True)\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"application-fetch_backend\": \"html_requests\",\n            \"application-minutes_between_check\": 180,\n            \"requests-default_ua-html_requests\": \"html-requests-user-agent\"\n        },\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"html-requests-user-agent\" in res.data\n    # default user-agent should have shown by now\n    # now add a custom one in the headers\n\n\n    # Add some headers to a request\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            \"tags\": \"testtag\",\n            \"fetch_backend\": 'html_requests',\n            # Important - also test case-insensitive\n            \"headers\": \"User-AGent: agent-from-watch\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b\"agent-from-watch\" in res.data\n    assert b\"html-requests-user-agent\" not in res.data\n    delete_all_watches(client)\n\ndef test_headers_textfile_in_request(client, live_server, measure_memory_usage, datastore_path):\n    import os\n\n    # Add our URL to the import page\n\n    webdriver_ua = \"Hello fancy webdriver UA 1.0\"\n    requests_ua = \"Hello basic requests UA 1.1\"\n\n    test_url = url_for('test_headers', _external=True)\n    if os.getenv('PLAYWRIGHT_DRIVER_URL'):\n        # Because its no longer calling back to localhost but from the browser container, set in test-only.yml\n        test_url = test_url.replace('localhost', 'cdio')\n\n    form_data = {\n        \"application-fetch_backend\": \"html_requests\",\n        \"application-minutes_between_check\": 180,\n        \"requests-default_ua-html_requests\": requests_ua\n    }\n\n    if os.getenv('PLAYWRIGHT_DRIVER_URL'):\n        form_data[\"requests-default_ua-html_webdriver\"] = webdriver_ua\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data=form_data,\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n    res = client.get(url_for(\"settings.settings_page\"))\n\n    # Only when some kind of real browser is setup\n    if os.getenv('PLAYWRIGHT_DRIVER_URL'):\n        assert b'requests-default_ua-html_webdriver' in res.data\n\n    # Field should always be there\n    assert b\"requests-default_ua-html_requests\" in res.data\n\n    # Add the test URL twice, we will check\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # Add some headers to a request\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            \"tags\": \"testtag\",\n            \"fetch_backend\": 'html_webdriver' if os.getenv('PLAYWRIGHT_DRIVER_URL') else 'html_requests',\n            \"headers\": \"xxx:ooo\\ncool:yeah\\r\\n\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    with open(os.path.join(datastore_path, 'headers-testtag.txt'), 'w') as f:\n        f.write(\"tag-header: test\\r\\nurl-header: http://example.com\")\n\n    with open(os.path.join(datastore_path, 'headers.txt'), 'w') as f:\n        f.write(\"global-header: nice\\r\\nnext-global-header: nice\\r\\nurl-header-global: http://example.com/global\")\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    with open(os.path.join(datastore_path, uuid, 'headers.txt'), 'w') as f:\n        f.write(\"watch-header: nice\\r\\nurl-header-watch: http://example.com/watch\")\n\n    wait_for_all_checks(client)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up, this actually is not super reliable and pytest can terminate before the check is ran\n    wait_for_all_checks(client)\n\n    # WARNING - pytest and 'wait_for_all_checks' shuts down before it has actually stopped processing when using pyppeteer fetcher\n    # so adding more time here\n    if os.getenv('FAST_PUPPETEER_CHROME_FETCHER'):\n        time.sleep(6)\n\n    res = client.get(url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"))\n    assert b\"Extra headers file found and will be added to this watch\" in res.data\n\n    # Not needed anymore\n    os.unlink(os.path.join(datastore_path, 'headers.txt'))\n    os.unlink(os.path.join(datastore_path, 'headers-testtag.txt'))\n\n    # The service should echo back the request verb\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"Global-Header:nice\" in res.data\n    assert b\"Next-Global-Header:nice\" in res.data\n    assert b\"Xxx:ooo\" in res.data\n    assert b\"Watch-Header:nice\" in res.data\n    assert b\"Tag-Header:test\" in res.data\n    assert b\"Url-Header:http://example.com\" in res.data\n    assert b\"Url-Header-Global:http://example.com/global\" in res.data\n    assert b\"Url-Header-Watch:http://example.com/watch\" in res.data\n\n    # Check the custom UA from system settings page made it through\n    if os.getenv('PLAYWRIGHT_DRIVER_URL'):\n        assert \"User-Agent:\".encode('utf-8') + webdriver_ua.encode('utf-8') in res.data\n    else:\n        assert \"User-Agent:\".encode('utf-8') + requests_ua.encode('utf-8') in res.data\n\n    # unlink headers.txt on start/stop\n    delete_all_watches(client)\n\ndef test_headers_validation(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    test_url = url_for('test_headers', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            \"fetch_backend\": 'html_requests',\n            \"headers\": \"User-AGent agent-from-watch\\r\\nsadfsadfsadfsdaf\\r\\n:foobar\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b\"Line 1 is missing a &#39;:&#39; separator.\" in res.data\n    assert b\"Line 3 has an empty key.\" in res.data\n\n"
  },
  {
    "path": "changedetectionio/tests/test_restock_itemprop.py",
    "content": "#!/usr/bin/env python3\nimport os\nimport time\n\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output, extract_UUID_from_client, delete_all_watches\nfrom ..notification import default_notification_format\n\ninstock_props = [\n    # LD+JSON with non-standard list of 'type' https://github.com/dgtlmoon/changedetection.io/issues/1833\n    '<script type=\\'application/ld+json\\'>{\"@context\": \"http://schema.org\",\"@type\": [\"Product\", \"SubType\"],\"name\": \"My test product\",\"description\":\"\",\"Offers\": {    \"@type\": \"Offer\",    \"offeredBy\": {        \"@type\": \"Organization\",        \"name\":\"Person\",       \"telephone\":\"+1 999 999 999\"    },    \"price\": $$PRICE$$,    \"priceCurrency\": \"EUR\",    \"url\": \"/some/url\", \"availability\": \"http://schema.org/InStock\"}        }</script>',\n    # LD JSON\n    '<script id=\"product-jsonld\" type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"Product\",\"brand\":{\"@type\":\"Brand\",\"name\":\"Ubiquiti\"},\"name\":\"UniFi Express\",\"sku\":\"UX\",\"description\":\"Impressively compact UniFi Cloud Gateway and WiFi 6 access point that runs UniFi Network. Powers an entire network or simply meshes as an access point.\",\"url\":\"https://store.ui.com/us/en/products/ux\",\"image\":{\"@type\":\"ImageObject\",\"url\":\"https://cdn.ecomm.ui.com/products/4ed25b4c-db92-4b98-bbf3-b0989f007c0e/123417a2-895e-49c7-ba04-b6cd8f6acc03.png\",\"width\":\"1500\",\"height\":\"1500\"},\"offers\":{\"@type\":\"Offer\",\"availability\":\"https://schema.org/InStock\",\"priceSpecification\":{\"@type\":\"PriceSpecification\",\"price\":$$PRICE$$,\"priceCurrency\":\"USD\",\"valueAddedTaxIncluded\":false}}}</script>',\n    '<script id=\"product-schema\" type=\"application/ld+json\">{\"@context\": \"https://schema.org\",\"@type\": \"Product\",\"itemCondition\": \"https://schema.org/NewCondition\",\"image\": \"//1.com/hmgo\",\"name\": \"Polo MuscleFit\",\"color\": \"Beige\",\"description\": \"Polo\",\"sku\": \"0957102010\",\"brand\": {\"@type\": \"Brand\",\"name\": \"H&M\"},\"category\": {\"@type\": \"Thing\",\"name\": \"Polo\"},\"offers\": [{\"@type\": \"Offer\",\"url\": \"https:/www2.xxxxxx.com/fr_fr/productpage.0957102010.html\",\"priceCurrency\": \"EUR\",\"price\": $$PRICE$$,\"availability\": \"http://schema.org/InStock\",\"seller\": {  \"@type\": \"Organization\", \"name\": \"H&amp;M\"}}]}</script>'\n    # Microdata\n    '<div itemscope itemtype=\"https://schema.org/Product\"><h1 itemprop=\"name\">Example Product</h1><p itemprop=\"description\">This is a sample product description.</p><div itemprop=\"offers\" itemscope itemtype=\"https://schema.org/Offer\"><p>Price: <span itemprop=\"price\">$$$PRICE$$</span></p><link itemprop=\"availability\" href=\"https://schema.org/InStock\" /></div></div>'\n]\n\nout_of_stock_props = [\n    # out of stock AND contains multiples\n    '<script type=\"application/ld+json\">{\"@context\":\"http://schema.org\",\"@type\":\"WebSite\",\"url\":\"https://www.medimops.de/\",\"potentialAction\":{\"@type\":\"SearchAction\",\"target\":\"https://www.medimops.de/produkte-C0/?fcIsSearch=1&searchparam={searchparam}\",\"query-input\":\"required name=searchparam\"}}</script><script type=\"application/ld+json\">{\"@context\":\"http://schema.org\",\"@type\":\"Product\",\"name\":\"Horsetrader: Robert Sangster and the Rise and Fall of the Sport of Kings\",\"image\":\"https://images2.medimops.eu/product/43a982/M00002551322-large.jpg\",\"productID\":\"isbn:9780002551328\",\"gtin13\":\"9780002551328\",\"category\":\"Livres en langue étrangère\",\"offers\":{\"@type\":\"Offer\",\"priceCurrency\":\"EUR\",\"price\":$$PRICE$$,\"itemCondition\":\"UsedCondition\",\"availability\":\"OutOfStock\"},\"brand\":{\"@type\":\"Thing\",\"name\":\"Patrick Robinson\",\"url\":\"https://www.momox-shop.fr/,patrick-robinson/\"}}</script>'\n]\n\ndef set_original_response(datastore_path, props_markup='', price=\"121.95\"):\n    props_markup=props_markup.replace('$$PRICE$$', price)\n    test_return_data = f\"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div>price: ${price}</div>\n     {props_markup}\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    time.sleep(1)\n    return None\n\ndef test_restock_itemprop_basic(client, live_server, measure_memory_usage, datastore_path):\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    # By default it should enable ('in_stock_processing') == 'all_changes'\n\n    for p in instock_props:\n        set_original_response(props_markup=p, datastore_path=datastore_path)\n        client.post(\n            url_for(\"ui.ui_views.form_quick_watch_add\"),\n            data={\"url\": test_url, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n            follow_redirects=True\n        )\n        wait_for_all_checks(client)\n        res = client.get(url_for(\"watchlist.index\"))\n        assert b'more than one price detected' not in res.data\n        assert b'has-restock-info' in res.data\n        assert b' in-stock' in res.data\n        assert b' not-in-stock' not in res.data\n        delete_all_watches(client)\n\n\n    for p in out_of_stock_props:\n        set_original_response(props_markup=p, datastore_path=datastore_path)\n        client.post(\n            url_for(\"ui.ui_views.form_quick_watch_add\"),\n            data={\"url\": test_url, \"tags\": '', 'processor': 'restock_diff'},\n            follow_redirects=True\n        )\n        wait_for_all_checks(client)\n        res = client.get(url_for(\"watchlist.index\"))\n\n        assert b'has-restock-info not-in-stock' in res.data\n\n        delete_all_watches(client)\n\ndef test_itemprop_price_change(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    # Out of the box 'Follow price changes' should be ON\n    test_url = url_for('test_endpoint', _external=True)\n\n    set_original_response(props_markup=instock_props[0], price=\"190.95\", datastore_path=datastore_path)\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n\n    # A change in price, should trigger a change by default\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'190.95' in res.data\n\n    # basic price change, look for notification\n    set_original_response(props_markup=instock_props[0], price='180.45', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'180.45' in res.data\n    assert b'has-unread-changes' in res.data\n    client.get(url_for(\"ui.mark_all_viewed\"), follow_redirects=True)\n    time.sleep(0.2)\n\n\n    # turning off price change trigger, but it should show the new price, with no change notification\n    set_original_response(props_markup=instock_props[0], price='120.45', datastore_path=datastore_path)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"processor_config_restock_diff-follow_price_changes\": \"\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'120.45' in res.data\n    assert b'has-unread-changes' not in res.data\n\n\n    delete_all_watches(client)\n\ndef _run_test_minmax_limit(client, extra_watch_edit_form, datastore_path):\n\n    delete_all_watches(client)\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    set_original_response(props_markup=instock_props[0], price=\"950.95\", datastore_path=datastore_path)\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n    wait_for_all_checks(client)\n\n    data = {\n        \"tags\": \"\",\n        \"url\": test_url,\n        \"headers\": \"\",\n        \"time_between_check-hours\": 5,\n        'fetch_backend': \"html_requests\",\n        \"time_between_check_use_default\": \"y\"\n    }\n    data.update(extra_watch_edit_form)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data=data,\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    client.get(url_for(\"ui.mark_all_viewed\"))\n\n    # price changed to something greater than min (900), BUT less than max (1100).. should be no change\n    set_original_response(props_markup=instock_props[0], price='1000.45', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"))\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b'more than one price detected' not in res.data\n    # BUT the new price should show, even tho its within limits\n    assert b'1,000.45' or b'1000.45' in res.data #depending on locale\n    assert b'has-unread-changes' not in res.data\n\n    # price changed to something LESS than min (900), SHOULD be a change\n    set_original_response(props_markup=instock_props[0], price='890.45', datastore_path=datastore_path)\n\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'890.45' in res.data\n    assert b'has-unread-changes' in res.data\n\n    client.get(url_for(\"ui.mark_all_viewed\"))\n\n\n    # 2715 - Price detection (once it crosses the \"lower\" threshold) again with a lower price - should trigger again!\n    set_original_response(props_markup=instock_props[0], price='820.45', datastore_path=datastore_path)\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'820.45' in res.data\n    assert b'has-unread-changes' in res.data\n    client.get(url_for(\"ui.mark_all_viewed\"))\n\n    # price changed to something MORE than max (1100.10), SHOULD be a change\n    set_original_response(props_markup=instock_props[0], price='1890.45', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    # Depending on the LOCALE it may be either of these (generally for US/default/etc)\n    assert b'1,890.45' in res.data or b'1890.45' in res.data\n    assert b'has-unread-changes' in res.data\n\n    delete_all_watches(client)\n\n\ndef test_restock_itemprop_minmax(client, live_server, measure_memory_usage, datastore_path):\n    \n    extras = {\n        \"processor_config_restock_diff-follow_price_changes\": \"y\",\n        \"processor_config_restock_diff-price_change_min\": 900.0,\n        \"processor_config_restock_diff-price_change_max\": 1100.10\n    }\n    _run_test_minmax_limit(client, extra_watch_edit_form=extras, datastore_path=datastore_path)\n\ndef test_restock_itemprop_with_tag(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"test-tag\"},\n        follow_redirects=True\n    )\n    assert b\"Tag added\" in res.data\n\n    res = client.post(\n        url_for(\"tags.form_tag_edit_submit\", uuid=\"first\"),\n        data={\"name\": \"test-tag\",\n              \"processor_config_restock_diff-follow_price_changes\": \"y\",\n              \"processor_config_restock_diff-price_change_min\": 900.0,\n              \"processor_config_restock_diff-price_change_max\": 1100.10,\n              \"overrides_watch\": \"y\", #overrides_watch should be restock_overrides_watch\n              },\n        follow_redirects=True\n    )\n\n    extras = {\n        \"tags\": \"test-tag\"\n    }\n\n    _run_test_minmax_limit(client, extra_watch_edit_form=extras,datastore_path=datastore_path)\n    delete_all_watches(client)\n\n\n\ndef test_itemprop_percent_threshold(client, live_server, measure_memory_usage, datastore_path):\n\n    delete_all_watches(client)\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    set_original_response(props_markup=instock_props[0], price=\"950.95\", datastore_path=datastore_path)\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n\n    # A change in price, should trigger a change by default\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"processor_config_restock_diff-follow_price_changes\": \"y\",\n              \"processor_config_restock_diff-price_change_threshold_percent\": 5.0,\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"\n              },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n\n    # Basic change should not trigger\n    set_original_response(props_markup=instock_props[0], price='960.45', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"))\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'960.45' in res.data\n    assert b'has-unread-changes' not in res.data\n\n    # Bigger INCREASE change than the threshold should trigger\n    set_original_response(props_markup=instock_props[0], price='1960.45', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"))\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'1,960.45' or b'1960.45' in res.data #depending on locale\n    assert b'has-unread-changes' in res.data\n\n\n    # Small decrease should NOT trigger\n    client.get(url_for(\"ui.mark_all_viewed\"))\n    set_original_response(props_markup=instock_props[0], price='1950.45', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"))\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'1,950.45' or b'1950.45' in res.data #depending on locale\n    assert b'has-unread-changes' not in res.data\n\n\n    # Re #2600 - Switch the mode to normal type and back, and see if the values stick..\n    ###################################################################################\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\"processor_config_restock_diff-follow_price_changes\": \"y\",\n              \"processor_config_restock_diff-price_change_threshold_percent\": 5.05,\n              \"processor\": \"text_json_diff\",\n              \"url\": test_url,\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"\n              },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    # And back again\n    live_server.app.config['DATASTORE'].data['watching'][uuid]['processor'] = 'restock_diff'\n    res = client.get(url_for(\"ui.ui_edit.edit_page\", uuid=uuid))\n    assert b'type=\"text\" value=\"5.05\"' in res.data\n\n    delete_all_watches(client)\n\n\n\ndef test_change_with_notification_values(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    if os.path.isfile(os.path.join(datastore_path, \"notification.txt\")):\n        os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n\n    test_url = url_for('test_endpoint', _external=True)\n    set_original_response(props_markup=instock_props[0], price='960.45', datastore_path=datastore_path)\n\n    notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')\n\n    ######################\n    # You must add a type of 'restock_diff' for its tokens to register as valid in the global settings\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n\n    # A change in price, should trigger a change by default\n    wait_for_all_checks(client)\n\n    # Should see new tokens register\n    res = client.get(url_for(\"settings.settings_page\"))\n    \n    assert b'{{restock.original_price}}' in res.data\n    assert b'Original price at first check' in res.data\n\n    #####################\n    # Set this up for when we remove the notification from the watch, it should fallback with these details\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": notification_url,\n              \"application-notification_title\": \"title new price {{restock.price}}\",\n              \"application-notification_body\": \"new price {{restock.price}}\",\n              \"application-notification_format\": default_notification_format,\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    # check tag accepts without error\n\n    # Check the watches in these modes add the tokens for validating\n    assert b\"A variable or function is not defined\" not in res.data\n\n    assert b\"Settings updated.\" in res.data\n\n\n    set_original_response(props_markup=instock_props[0], price='960.45', datastore_path=datastore_path)\n    # A change in price, should trigger a change by default\n    set_original_response(props_markup=instock_props[0], price='1950.45', datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"))\n    wait_for_all_checks(client)\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n    assert os.path.isfile(os.path.join(datastore_path, \"notification.txt\")), \"Notification received\"\n    with open(os.path.join(datastore_path, \"notification.txt\"), 'r') as f:\n        notification = f.read()\n        assert \"new price 1950.45\" in notification\n        assert \"title new price 1950.45\" in notification\n\n    ## Now test the \"SEND TEST NOTIFICATION\" is working\n    os.unlink(os.path.join(datastore_path, \"notification.txt\"))\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    res = client.post(url_for(\"ui.ui_notification.ajax_callback_send_notification_test\", watch_uuid=uuid), data={}, follow_redirects=True)\n    wait_for_notification_endpoint_output(datastore_path=datastore_path)\n    assert os.path.isfile(os.path.join(datastore_path, \"notification.txt\")), \"Notification received\"\n\n    delete_all_watches(client)\n\ndef test_data_sanity(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    delete_all_watches(client)\n\n    test_url = url_for('test_endpoint', _external=True)\n    test_url2 = url_for('test_endpoint2', _external=True)\n    set_original_response(props_markup=instock_props[0], price=\"950.95\", datastore_path=datastore_path)\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'950.95' in res.data\n\n    # Check the restock model object doesnt store the value by mistake and used in a new one\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url2, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert str(res.data.decode()).count(\"950.95\") == 1, \"Price should only show once (for the watch added, no other watches yet)\"\n\n    ## different test, check the edit page works on an empty request result\n    delete_all_watches(client)\n\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url2, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"))\n    assert test_url2.encode('utf-8') in res.data\n\n    delete_all_watches(client)\n\n# All examples should give a prive of 666.66\ndef test_special_prop_examples(client, live_server, measure_memory_usage, datastore_path):\n    import glob\n    \n\n    test_url = url_for('test_endpoint', _external=True)\n    check_path = os.path.join(os.path.dirname(__file__), \"itemprop_test_examples\", \"*.txt\")\n    files = glob.glob(check_path)\n    assert files\n    for test_example_filename in files:\n        with open(test_example_filename, 'r') as example_f:\n            with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as test_f:\n                test_f.write(f\"<html><body>{example_f.read()}</body></html>\")\n\n            # Now fetch it and check the price worked\n            client.post(\n                url_for(\"ui.ui_views.form_quick_watch_add\"),\n                data={\"url\": test_url, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n                follow_redirects=True\n            )\n            wait_for_all_checks(client)\n            res = client.get(url_for(\"watchlist.index\"))\n            assert b'ception' not in res.data\n            assert b'155.55' in res.data\n\n    delete_all_watches(client)\n\n\ndef test_itemprop_as_str(client, live_server, measure_memory_usage, datastore_path):\n\n    test_return_data = f\"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n<span itemprop=\"offers\" itemscope itemtype=\"http://schema.org/Offer\">\n<meta content=\"767.55\" itemprop=\"price\"/>\n<meta content=\"EUR\" itemprop=\"priceCurrency\"/>\n<meta content=\"InStock\" itemprop=\"availability\"/>\n<meta content=\"https://www.123-test.dk\" itemprop=\"url\"/>\n</span>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'restock tests', 'processor': 'restock_diff'},\n        follow_redirects=True\n    )\n\n    client.get(url_for(\"ui.form_watch_checknow\"))\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'767.55' in res.data"
  },
  {
    "path": "changedetectionio/tests/test_rss.py",
    "content": "#!/usr/bin/env python3\nimport os\nimport time\nfrom flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \\\n    extract_UUID_from_client, delete_all_watches\nfrom loguru import logger\nfrom ..blueprint.rss import RSS_FORMAT_TYPES\n\n\ndef set_original_cdata_xml(datastore_path):\n    test_return_data = \"\"\"<rss xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:media=\"http://search.yahoo.com/mrss/\" xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n    <channel>\n    <title>Gizi</title>\n    <link>https://test.com</link>\n    <atom:link href=\"https://testsite.com\" rel=\"self\" type=\"application/rss+xml\"/>\n    <description>\n    <![CDATA[ The Future Could Be Here ]]>\n    </description>\n    <language>en</language>\n    <item>\n    <title>\n    <![CDATA[ <img src=\"https://testsite.com/hacked.jpg\"> Hackers can access your computer ]]>\n    </title>\n    <link>https://testsite.com/news/12341234234</link>\n    <description>\n    <![CDATA[ <img class=\"type:primaryImage\" src=\"https://testsite.com/701c981da04869e.jpg\"/><p>The days of Terminator and The Matrix could be closer. But be positive.</p><p><a href=\"https://testsite.com\">Read more link...</a></p> ]]>\n    </description>\n    <category>cybernetics</category>\n    <category>rand corporation</category>\n    <pubDate>Tue, 17 Oct 2023 15:10:00 GMT</pubDate>\n    <guid isPermaLink=\"false\">1850933241</guid>\n    <dc:creator>\n    <![CDATA[ Mr Hacker News ]]>\n    </dc:creator>\n    <media:thumbnail url=\"https://testsite.com/thumbnail-c224e10d81488e818701c981da04869e.jpg\"/>\n    </item>\n\n    <item>\n        <title>    Some other title    </title>\n        <link>https://testsite.com/news/12341234236</link>\n        <description>\n        Some other description\n        </description>\n    </item>    \n    </channel>\n    </rss>\n            \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n\ndef set_html_content(datastore_path, content):\n    test_return_data = f\"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>{content}</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    # Write as UTF-8 encoded bytes\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"wb\") as f:\n        f.write(test_return_data.encode('utf-8'))\n\n\ndef test_rss_feed_empty(client, live_server, measure_memory_usage, datastore_path):\n\n    rss_token = extract_rss_token_from_UI(client)\n\n    res = client.get(\n        url_for(\"rss.feed\", token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b'xml' in res.data\n\ndef test_rss_and_token(client, live_server, measure_memory_usage, datastore_path):\n    #   #  live_server_setup(live_server) # Setup on conftest per function\n\n    set_original_response(datastore_path=datastore_path)\n    rss_token = extract_rss_token_from_UI(client)\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_random_content_endpoint', _external=True))\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    set_modified_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Add our URL to the import page\n    res = client.get(\n        url_for(\"rss.feed\", token=\"bad token\", _external=True),\n        follow_redirects=True\n    )\n\n    assert b\"Access denied, bad token\" in res.data\n\n    res = client.get(\n        url_for(\"rss.feed\", token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    assert b\"Access denied, bad token\" not in res.data\n    assert b\"Random content\" in res.data\n\n    delete_all_watches(client)\n\ndef test_basic_cdata_rss_markup(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    set_original_cdata_xml(datastore_path)\n    # Rarely do endpoints give the right header, usually just text/xml, so we check also for <rss\n    # This also triggers the automatic CDATA text parser so the RSS goes back a nice content list\n    test_url = url_for('test_endpoint', content_type=\"text/xml; charset=UTF-8\", _external=True)\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b'CDATA' not in res.data\n    assert b'<![' not in res.data\n    assert b'Hackers can access your computer' in res.data\n    assert b'The days of Terminator' in res.data\n    delete_all_watches(client)\n\ndef test_rss_xpath_filtering(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    set_original_cdata_xml(datastore_path)\n\n    test_url = url_for('test_endpoint', content_type=\"application/atom+xml; charset=UTF-8\", _external=True)\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid, unpause_on_save=1),\n        data={\n                \"include_filters\": \"//item/title\",\n                \"fetch_backend\": \"html_requests\",\n                \"headers\": \"\",\n                \"proxy\": \"no-proxy\",\n                \"tags\": \"\",\n                \"url\": test_url,\n                \"time_between_check_use_default\": \"y\",\n              },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b'CDATA' not in res.data\n    assert b'<![' not in res.data\n    # #1874  All but the first <title was getting selected\n    # Convert any HTML with just a top level <title> to <h1> to be sure title renders\n\n    assert b'Hackers can access your computer' in res.data # Should ONLY be selected by the xpath\n    assert b'Some other title' in res.data  # Should ONLY be selected by the xpath\n    assert b'The days of Terminator' not in res.data # Should NOT be selected by the xpath\n    assert b'Some other description' not in res.data  # Should NOT be selected by the xpath\n\n    delete_all_watches(client)\n\n\ndef test_rss_bad_chars_breaking(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"This should absolutely trigger the RSS builder to go into worst state mode\n\n    - source: prefix means no html conversion (which kinda filters out the bad stuff)\n    - Binary data\n    - Very long so that the saving is performed by Brotli (and decoded back to bytes)\n\n    Otherwise feedgen should support regular unicode\n    \"\"\"\n    \n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        ten_kb_string = \"A\" * 10_000\n        f.write(ten_kb_string)\n\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": \"source:\"+test_url},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # Set the bad content\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        jpeg_bytes = \"\\xff\\xd8\\xff\\xe0\\x00\\x10XXXXXXXX\\x00\\x01\\x02\\x00\\x00\\x01\\x00\\x01\\x00\\x00\"  # JPEG header\n        jpeg_bytes += \"A\" * 10_000\n\n        f.write(jpeg_bytes)\n\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n    wait_for_all_checks(client)\n    rss_token = extract_rss_token_from_UI(client)\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    i=0\n    from loguru import logger\n    # Because chardet could take a long time\n    while i<=10:\n        logger.debug(f\"History was {live_server.app.config['DATASTORE'].data['watching'][uuid].history_n}..\")\n        if live_server.app.config['DATASTORE'].data['watching'][uuid].history_n ==2:\n            break\n            i+=1\n        time.sleep(2)\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2\n\n    # Check RSS feed is still working\n    res = client.get(\n        url_for(\"rss.feed\", uuid=uuid, token=rss_token),\n        follow_redirects=False # Important! leave this off! it should not redirect\n    )\n    assert res.status_code == 200\n\n    #assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2\n    #assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2\n\n\ndef test_rss_single_watch_feed(client, live_server, measure_memory_usage, datastore_path):\n\n    app_rss_token = live_server.app.config['DATASTORE'].data['settings']['application'].get('rss_access_token')\n    rss_content_format = live_server.app.config['DATASTORE'].data['settings']['application'].get('rss_content_format')\n\n    set_original_response(datastore_path=datastore_path)\n\n\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for('rss.rss_single_watch', uuid=uuid, token=app_rss_token),\n        follow_redirects=False\n    )\n\n    assert res.status_code == 400\n    assert b'not have enough history' in res.data\n\n    set_modified_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for('rss.rss_single_watch', uuid=uuid, token=app_rss_token),\n        follow_redirects=False\n    )\n    assert res.status_code == 200\n    import xml.etree.ElementTree as ET\n    root = ET.fromstring(res.data)\n\n    def check_formatting(expected_type, content, url):\n        logger.debug(f\"Checking formatting type {expected_type}\")\n        if expected_type == 'text':\n            assert '<p>' not in content\n            assert 'body' not in content\n            assert '(changed) Which is across multiple lines\\n'\n            assert 'modified head title had a change.' # Because it picked it up <title> as watch_title in default template\n        elif expected_type == 'html':\n            assert '<p>' in content\n            assert '<body>' in content\n            assert '<p>(changed) Which is across multiple lines<br>' in content\n            assert f'href=\"{url}\">modified head title had a change.</a>'\n        elif expected_type == 'htmlcolor':\n            assert '<body>' in content\n            assert ' role=\"note\" aria-label=\"Changed text\" title=\"Changed text\">Which is across multiple lines</span>' in content\n            assert f'href=\"{url}\">modified head title had a change.</a>'\n        else:\n            raise Exception(f\"Unknown type {expected_type}\")\n\n\n    item = root.findall('.//item')[0].findtext('description')\n    check_formatting(expected_type=rss_content_format, content=item, url=test_url)\n\n    # Now the default one is over, lets try all the others\n    for k in list(RSS_FORMAT_TYPES.keys()):\n        res = client.post(\n            url_for(\"settings.settings_page\"),\n            data={\"application-rss_content_format\": k},\n            follow_redirects=True\n        )\n        assert b'Settings updated' in res.data\n\n        res = client.get(\n            url_for('rss.rss_single_watch', uuid=uuid, token=app_rss_token),\n            follow_redirects=False\n        )\n        assert res.status_code == 200\n        root = ET.fromstring(res.data)\n        item = root.findall('.//item')[0].findtext('description')\n        check_formatting(expected_type=k, content=item, url=test_url)\n\n    # Test RSS entry order: Create multiple versions and verify newest appears first\n    for version in range(3, 6):  # Create versions 3, 4, 5\n        set_html_content(datastore_path, f\"Version {version} content\")\n        client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        wait_for_all_checks(client)\n        time.sleep(0.5)  # Small delay to ensure different timestamps\n\n    # Fetch RSS feed again to verify order\n    res = client.get(\n        url_for('rss.rss_single_watch', uuid=uuid, token=app_rss_token),\n        follow_redirects=False\n    )\n    assert res.status_code == 200\n\n    # Parse RSS and check order (newest first)\n    root = ET.fromstring(res.data)\n    items = root.findall('.//item')\n    assert len(items) >= 3, f\"Expected at least 3 items, got {len(items)}\"\n\n    # Get descriptions from first 3 items\n    descriptions = []\n    for item in items[:3]:\n        desc = item.findtext('description')\n        descriptions.append(desc if desc else \"\")\n\n    # First item should contain newest change (Version 5)\n    # Note: Content may include [edit watch] links and diff markup with HTML tags\n    # Diff markup may split version numbers or keep them together depending on change type\n    assert (\">5<\" in descriptions[0] or \"Version 5\" in descriptions[0]) and \"content\" in descriptions[0], \\\n        f\"First item should show newest change, but got: {descriptions[0][:500]}\"\n\n    # Second item should contain Version 4\n    assert (\">4<\" in descriptions[1] or \"Version 4\" in descriptions[1]) and \"content\" in descriptions[1], \\\n        f\"Second item should show Version 4, but got: {descriptions[1][:500]}\"\n\n    # Third item should contain Version 3\n    assert (\">3<\" in descriptions[2] or \"Version 3\" in descriptions[2]) and \"content\" in descriptions[2], \\\n        f\"Third item should show Version 3, but got: {descriptions[2][:500]}\"\n\n"
  },
  {
    "path": "changedetectionio/tests/test_rss_group.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, extract_rss_token_from_UI, get_UUID_for_tag_name, delete_all_watches\nimport os\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Watch 1 content</p>\n     <p>Watch 2 content</p>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Watch 1 content MODIFIED</p>\n     <p>Watch 2 content CHANGED</p>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef test_rss_group(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that RSS feed for a specific tag/group shows only watches in that group\n    and displays changes correctly.\n    \"\"\"\n\n    set_original_response(datastore_path=datastore_path)\n\n    # Create a tag/group\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"test-rss-group\"},\n        follow_redirects=True\n    )\n    assert b\"Tag added\" in res.data\n    assert b\"test-rss-group\" in res.data\n\n    # Get the tag UUID\n    tag_uuid = get_UUID_for_tag_name(client, name=\"test-rss-group\")\n    assert tag_uuid is not None\n\n    # Add first watch with the tag\n    test_url_1 = url_for('test_endpoint', _external=True) + \"?watch=1\"\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url_1, \"tags\": 'test-rss-group'},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    # Add second watch with the tag\n    test_url_2 = url_for('test_endpoint', _external=True) + \"?watch=2\"\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url_2, \"tags\": 'test-rss-group'},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    # Add a third watch WITHOUT the tag (should not appear in RSS)\n    test_url_3 = url_for('test_endpoint', _external=True) + \"?watch=3\"\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url_3, \"tags\": 'other-tag'},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    # Wait for initial checks to complete\n    wait_for_all_checks(client)\n\n    # Ensure initial snapshots are saved\n    assert wait_for_watch_history(client, min_history_count=1, timeout=10), \"Watches did not save initial snapshots\"\n\n    # Trigger a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Recheck all watches\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Ensure all watches have sufficient history for RSS generation\n    assert wait_for_watch_history(client, min_history_count=2, timeout=10), \"Watches did not accumulate sufficient history\"\n\n    # Get RSS token\n    rss_token = extract_rss_token_from_UI(client)\n    assert rss_token is not None\n\n    # Request RSS feed for the specific tag/group using the new endpoint\n    res = client.get(\n        url_for(\"rss.rss_tag_feed\", tag_uuid=tag_uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n\n    # Verify response is successful\n    assert res.status_code == 200\n    assert b\"<?xml\" in res.data or b\"<rss\" in res.data\n\n    # Verify the RSS feed contains the tag name in the title\n    assert b\"test-rss-group\" in res.data\n\n    # Verify watch 1 and watch 2 are in the RSS feed (they have the tag)\n    assert b\"watch=1\" in res.data\n    assert b\"watch=2\" in res.data\n\n    # Verify watch 3 is NOT in the RSS feed (it doesn't have the tag)\n    assert b\"watch=3\" not in res.data\n\n    # Verify the changes are shown in the RSS feed\n    assert b\"MODIFIED\" in res.data or b\"CHANGED\" in res.data\n\n    # Verify it's actual RSS/XML format\n    assert b\"<rss\" in res.data or b\"<feed\" in res.data\n\n    # Test with invalid tag UUID - should return 404\n    res = client.get(\n        url_for(\"rss.rss_tag_feed\", tag_uuid=\"invalid-uuid-12345\", token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    assert res.status_code == 404\n    assert b\"not found\" in res.data\n\n    # Test with invalid token - should return 403\n    res = client.get(\n        url_for(\"rss.rss_tag_feed\", tag_uuid=tag_uuid, token=\"wrong-token\", _external=True),\n        follow_redirects=True\n    )\n    assert res.status_code == 403\n    assert b\"Access denied\" in res.data\n\n    # Clean up\n    delete_all_watches(client)\n    res = client.get(url_for(\"tags.delete_all\"), follow_redirects=True)\n    assert b'All tags deleted' in res.data\n\n\ndef test_rss_group_empty_tag(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that RSS feed for a tag with no watches returns valid but empty RSS.\n    \"\"\"\n\n    # Create a tag with no watches\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"empty-tag\"},\n        follow_redirects=True\n    )\n    assert b\"Tag added\" in res.data\n\n    tag_uuid = get_UUID_for_tag_name(client, name=\"empty-tag\")\n    assert tag_uuid is not None\n\n    # Get RSS token\n    rss_token = extract_rss_token_from_UI(client)\n\n    # Request RSS feed for empty tag\n    res = client.get(\n        url_for(\"rss.rss_tag_feed\", tag_uuid=tag_uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n\n    # Should still return 200 with valid RSS\n    assert res.status_code == 200\n    assert b\"<?xml\" in res.data or b\"<rss\" in res.data\n    assert b\"empty-tag\" in res.data\n\n    # Clean up\n    res = client.get(url_for(\"tags.delete_all\"), follow_redirects=True)\n    assert b'All tags deleted' in res.data\n\n\ndef test_rss_group_only_unviewed(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that RSS feed for a tag only shows unviewed watches.\n    \"\"\"\n\n    set_original_response(datastore_path=datastore_path)\n\n    # Create a tag\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"unviewed-test\"},\n        follow_redirects=True\n    )\n    assert b\"Tag added\" in res.data\n\n    tag_uuid = get_UUID_for_tag_name(client, name=\"unviewed-test\")\n\n    # Add two watches with the tag\n    test_url_1 = url_for('test_endpoint', _external=True) + \"?unviewed=1\"\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url_1, \"tags\": 'unviewed-test'},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    test_url_2 = url_for('test_endpoint', _external=True) + \"?unviewed=2\"\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url_2, \"tags\": 'unviewed-test'},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    wait_for_all_checks(client)\n    assert wait_for_watch_history(client, min_history_count=1, timeout=10), \"Initial snapshots not saved\"\n\n    # Trigger changes\n    set_modified_response(datastore_path=datastore_path)\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    assert wait_for_watch_history(client, min_history_count=2, timeout=10), \"History not accumulated\"\n\n    # Get RSS token\n    rss_token = extract_rss_token_from_UI(client)\n\n    # Request RSS feed - should show both watches (both unviewed)\n    res = client.get(\n        url_for(\"rss.rss_tag_feed\", tag_uuid=tag_uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b\"unviewed=1\" in res.data\n    assert b\"unviewed=2\" in res.data\n\n    # Mark all as viewed\n    res = client.get(url_for('ui.mark_all_viewed'), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Request RSS feed again - should be empty now (no unviewed watches)\n    res = client.get(\n        url_for(\"rss.rss_tag_feed\", tag_uuid=tag_uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    # Should not contain the watch URLs anymore since they're viewed\n    assert b\"unviewed=1\" not in res.data\n    assert b\"unviewed=2\" not in res.data\n\n    # Clean up\n    delete_all_watches(client)\n    res = client.get(url_for(\"tags.delete_all\"), follow_redirects=True)\n    assert b'All tags deleted' in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_rss_reader_mode.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nimport os\n\nfrom flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \\\n    extract_UUID_from_client, delete_all_watches\n\ndef set_xmlns_purl_content(datastore_path, extra=\"\"):\n    data=f\"\"\"<rss xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:dc=\"https://purl.org/dc/elements/1.1/\" xmlns:media=\"http://search.yahoo.com/mrss/\" xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n<channel>\n<atom:link href=\"https://www.xxxxxxxtechxxxxx.com/feeds.xml\" rel=\"self\" type=\"application/rss+xml\"/>\n<title>\n<![CDATA[ Latest from xxxxxxxtechxxxxx ]]>\n</title>\n<link>https://www.xxxxx.com</link>\n<description>\n<![CDATA[ All the latest content from the xxxxxxxtechxxxxx team ]]>\n</description>\n<lastBuildDate>Wed, 19 Nov 2025 15:00:00 +0000</lastBuildDate>\n<language>en</language>\n<item>\n<title>\n<![CDATA[ Sony Xperia 1 VII review: has Sony’s long-standing Xperia family lost what it takes to compete? ]]>\n</title>\n<dc:content>\n<![CDATA[  {{extra}}  a little harder, dc-content. blue often quite tough and purple usually very difficult.</p><p>On the plus side, you don't technically need to solve the final one, as you'll be able to answer that one by a process of elimination. What's more, you can make up to four mistakes, which gives you a little bit of breathing room.</p><p>It's a little more involved than something like Wordle, however, and there are plenty of opportunities for the game to trip you up with tricks. For instance, watch out for homophones and other word games that could disguise the answers.</p><p>It's playable for free via the <a href=\"https://www.nytimes.com/games/strands\" target=\"_blank\">NYT Games site</a> on desktop or mobile.</p></article></section> ]]>\n</dc:content>\n<link>https://www.xxxxxxx.com/gaming/nyt-connections-today-answers-hints-20-november-2025</link>\n<description>\n<![CDATA[ Looking for NYT Connections answers and hints? Here's all you need to know to solve today's game, plus my commentary on the puzzles. ]]>\n</description>\n<guid isPermaLink=\"false\">N2C2T6DztpWdxSdKpSUx89</guid>\n<enclosure url=\"https://cdn.mos.cms.futurecdn.net/RCGfdf3yhQ9W3MHbTRT6yk-1280-80.jpg\" type=\"image/jpeg\" length=\"0\"/>\n<pubDate>Wed, 19 Nov 2025 15:00:00 +0000</pubDate>\n<category>\n<![CDATA[ Gaming ]]>\n</category>\n<dc:creator>\n<![CDATA[ Johnny Dee ]]>\n</dc:creator>\n<media:content type=\"image/jpeg\" url=\"https://cdn.mos.cms.futurecdn.net/RCGfdf3yhQ9W3MHbTRT6yk-1280-80.jpg\">\n<media:credit>\n<![CDATA[ New York Times ]]>\n</media:credit>\n<media:text>\n<![CDATA[ NYT Connections homescreen on a phone, on a purple background ]]>\n</media:text>\n<media:title type=\"plain\">\n<![CDATA[ NYT Connections homescreen on a phone, on a purple background ]]>\n</media:title>\n</media:content>\n<media:thumbnail url=\"https://cdn.mos.cms.futurecdn.net/RCGfdf3yhQ9W3MHbTRT6yk-1280-80.jpg\"/>\n</item>\n    </channel>\n    </rss>\n            \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(data)\n\n\n\n\ndef set_original_cdata_xml(datastore_path):\n    test_return_data = \"\"\"<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n<channel>\n<title>Security Bulletins on wetscale</title>\n<link>https://wetscale.com/security-bulletins/</link>\n<description>Recent security bulletins from wetscale</description>\n<lastBuildDate>Fri, 10 Oct 2025 14:58:11 GMT</lastBuildDate>\n<docs>https://validator.w3.org/feed/docs/rss2.html</docs>\n<generator>wetscale.com</generator>\n<language>en-US</language>\n<copyright>© 2025 wetscale Inc. All rights reserved.</copyright>\n<atom:link href=\"https://wetscale.com/security-bulletins/index.xml\" rel=\"self\" type=\"application/rss+xml\"/>\n<item>\n<title>TS-2025-005</title>\n<link>https://wetscale.com/security-bulletins/#ts-2025-005</link>\n<guid>https://wetscale.com/security-bulletins/#ts-2025-005</guid>\n<pubDate>Thu, 07 Aug 2025 00:00:00 GMT</pubDate>\n<description><p>Wet noodles escape<br><p>they also found themselves outside</p> </description>\n</item>\n\n\n<item>\n<title>TS-2025-004</title>\n<link>https://wetscale.com/security-bulletins/#ts-2025-004</link>\n<guid>https://wetscale.com/security-bulletins/#ts-2025-004</guid>\n<pubDate>Tue, 27 May 2025 00:00:00 GMT</pubDate>\n<description>\n    <![CDATA[ <img class=\"type:primaryImage\" src=\"https://testsite.com/701c981da04869e.jpg\"/><p>The days of Terminator and The Matrix could be closer. But be positive.</p><p><a href=\"https://testsite.com\">Read more link...</a></p> ]]>\n</description>\n</item>\n    </channel>\n    </rss>\n            \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n\ndef test_rss_reader_mode(client, live_server, measure_memory_usage, datastore_path):\n    set_original_cdata_xml(datastore_path=datastore_path)\n\n    # Rarely do endpoints give the right header, usually just text/xml, so we check also for <rss\n    # This also triggers the automatic CDATA text parser so the RSS goes back a nice content list\n    test_url = url_for('test_endpoint', content_type=\"text/xml; charset=UTF-8\", _external=True)\n    live_server.app.config['DATASTORE'].data['settings']['application']['rss_reader_mode'] = True\n\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    dates = list(watch.history.keys())\n    snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])\n    assert 'Wet noodles escape' in snapshot_contents\n    assert '<br>' not in snapshot_contents\n    assert '&lt;' not in snapshot_contents\n    assert 'The days of Terminator and The Matrix' in snapshot_contents\n    assert 'PubDate: Thu, 07 Aug 2025 00:00:00 GMT' in snapshot_contents\n    delete_all_watches(client)\n\ndef test_rss_reader_mode_with_css_filters(client, live_server, measure_memory_usage, datastore_path):\n    set_original_cdata_xml(datastore_path=datastore_path)\n\n    # Rarely do endpoints give the right header, usually just text/xml, so we check also for <rss\n    # This also triggers the automatic CDATA text parser so the RSS goes back a nice content list\n    test_url = url_for('test_endpoint', content_type=\"text/xml; charset=UTF-8\", _external=True)\n    live_server.app.config['DATASTORE'].data['settings']['application']['rss_reader_mode'] = True\n\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'include_filters': [\".last\"]})\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    dates = list(watch.history.keys())\n    snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])\n    assert 'Wet noodles escape' not in snapshot_contents\n    assert '<br>' not in snapshot_contents\n    assert '&lt;' not in snapshot_contents\n    assert 'The days of Terminator and The Matrix' in snapshot_contents\n    delete_all_watches(client)\n\n\ndef test_xmlns_purl_content(client, live_server, measure_memory_usage, datastore_path):\n    set_xmlns_purl_content(datastore_path=datastore_path)\n\n    # Rarely do endpoints give the right header, usually just text/xml, so we check also for <rss\n    # This also triggers the automatic CDATA text parser so the RSS goes back a nice content list\n    #test_url = url_for('test_endpoint', content_type=\"text/xml; charset=UTF-8\", _external=True)\n\n    # Because NO utf-8 was specified here, we should be able to recover it in requests or other somehow.\n    test_url = url_for('test_endpoint', content_type=\"text/xml;\", _external=True)\n    live_server.app.config['DATASTORE'].data['settings']['application']['rss_reader_mode'] = True\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'include_filters': [\".last\"]})\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    dates = list(watch.history.keys())\n    snapshot_contents = watch.get_history_snapshot(timestamp=dates[0])\n    assert \"Title: Sony Xperia 1 VII review: has Sony’s long-standing Xperia family lost what it takes to compete?\" in snapshot_contents\n    assert \"dc-content\" in snapshot_contents\n"
  },
  {
    "path": "changedetectionio/tests/test_rss_single_watch.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nimport os\nimport xml.etree.ElementTree as ET\nfrom flask import url_for\n\nfrom .restock.test_restock import set_original_response\nfrom .util import live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, extract_UUID_from_client, delete_all_watches, set_modified_response\nfrom ..notification import default_notification_format\n\n\n# Watch with no change should not break the output\ndef test_rss_feed_empty(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n    rss_token = extract_rss_token_from_UI(client)\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    # Request RSS feed for the single watch\n    res = client.get(\n        url_for(\"rss.rss_single_watch\", uuid=uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    assert res.status_code == 400\n    assert b'does not have enough history snapshots to show' in res.data\n    delete_all_watches(client)\n\ndef test_rss_single_watch_order(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that single watch RSS feed shows changes in correct order (newest first).\n    \"\"\"\n\n    # Create initial content\n    def set_response(datastore_path, version):\n        test_return_data = f\"\"\"<html>\n           <body>\n         <p>Version {version} content</p>\n         </body>\n         </html>\n        \"\"\"\n        with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n            f.write(test_return_data)\n\n    # Start with version 1\n    set_response(datastore_path, 1)\n\n    # Add a watch\n    test_url = url_for('test_endpoint', _external=True) + \"?order_test=1\"\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": 'test-tag'},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    # Get the watch UUID\n    watch_uuid = extract_UUID_from_client(client)\n\n    # Wait for initial check\n    wait_for_all_checks(client)\n\n    # Create multiple versions by triggering changes\n    for version in range(2, 6):  # Create versions 2, 3, 4, 5\n        set_response(datastore_path, version)\n        res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n        wait_for_all_checks(client)\n        time.sleep(0.5)  # Small delay to ensure different timestamps\n\n    # Get RSS token\n    rss_token = extract_rss_token_from_UI(client)\n\n    # Request RSS feed for the single watch\n    res = client.get(\n        url_for(\"rss.rss_single_watch\", uuid=watch_uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n\n    # Should return valid RSS\n    assert res.status_code == 200\n    assert b\"<?xml\" in res.data or b\"<rss\" in res.data\n\n    # Parse the RSS/XML\n    root = ET.fromstring(res.data)\n\n    # Find all items (RSS 2.0) or entries (Atom)\n    items = root.findall('.//item')\n    if not items:\n        items = root.findall('.//{http://www.w3.org/2005/Atom}entry')\n\n    # Should have multiple items\n    assert len(items) >= 3, f\"Expected at least 3 items, got {len(items)}\"\n\n    # Get the descriptions/content from first 3 items\n    descriptions = []\n    for item in items[:3]:\n        # Try RSS format first\n        desc = item.findtext('description')\n        if not desc:\n            # Try Atom format\n            content_elem = item.find('{http://www.w3.org/2005/Atom}content')\n            if content_elem is not None:\n                desc = content_elem.text\n        descriptions.append(desc if desc else \"\")\n\n    print(f\"First item content (first 200 chars): {descriptions[0][:200] if descriptions[0] else 'None'}\")\n    print(f\"Second item content (first 200 chars): {descriptions[1][:200] if descriptions[1] else 'None'}\")\n    print(f\"Third item content (first 200 chars): {descriptions[2][:200] if descriptions[2] else 'None'}\")\n\n    # The FIRST item should contain the NEWEST change (Version 5)\n    # The SECOND item should contain Version 4\n    # The THIRD item should contain Version 3\n    # Note: Content may include [edit watch] links and diff markup like \"(added) 5\"\n    # So we check for \"5 content\" which appears in \"Version 5 content\"\n    assert \"5 content\" in descriptions[0], \\\n        f\"First item should show newest change (with '5 content'), but got: {descriptions[0][:500]}\"\n\n    # Verify the order is correct\n    assert \"4 content\" in descriptions[1], \\\n        f\"Second item should show Version 4 (with '4 content'), but got: {descriptions[1][:500]}\"\n\n    assert \"3 content\" in descriptions[2], \\\n        f\"Third item should show Version 3 (with '3 content'), but got: {descriptions[2][:500]}\"\n\n    # Clean up\n    delete_all_watches(client)\n\n\ndef test_rss_categories_from_tags(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that RSS feeds include category tags from watch tags.\n    \"\"\"\n\n    # Create initial content\n    test_return_data = \"\"\"<html>\n       <body>\n     <p>Test content for RSS categories</p>\n     </body>\n     </html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    # Create some tags first\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"Security\"},\n        follow_redirects=True\n    )\n\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"Python\"},\n        follow_redirects=True\n    )\n\n    res = client.post(\n        url_for(\"tags.form_tag_add\"),\n        data={\"name\": \"Tech News\"},\n        follow_redirects=True\n    )\n\n    # Add a watch with tags\n    test_url = url_for('test_endpoint', _external=True) + \"?category_test=1\"\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": \"Security, Python, Tech News\"},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data\n\n    # Get the watch UUID\n    watch_uuid = extract_UUID_from_client(client)\n\n    # Wait for initial check\n    wait_for_all_checks(client)\n\n    # Trigger one change\n    test_return_data_v2 = \"\"\"<html>\n       <body>\n     <p>Updated content for RSS categories</p>\n     </body>\n     </html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data_v2)\n\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Get RSS token\n    rss_token = extract_rss_token_from_UI(client)\n\n    # Test 1: Check single watch RSS feed\n    res = client.get(\n        url_for(\"rss.rss_single_watch\", uuid=watch_uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b\"<?xml\" in res.data or b\"<rss\" in res.data\n\n    # Parse the RSS/XML\n    root = ET.fromstring(res.data)\n\n    # Find all items\n    items = root.findall('.//item')\n    assert len(items) >= 1, \"Expected at least 1 item in RSS feed\"\n\n    # Get categories from first item\n    categories = [cat.text for cat in items[0].findall('category')]\n\n    print(f\"Found categories in single watch RSS: {categories}\")\n\n    # Should have all three categories\n    assert \"Security\" in categories, f\"Expected 'Security' category, got: {categories}\"\n    assert \"Python\" in categories, f\"Expected 'Python' category, got: {categories}\"\n    assert \"Tech News\" in categories, f\"Expected 'Tech News' category, got: {categories}\"\n    assert len(categories) == 3, f\"Expected 3 categories, got {len(categories)}: {categories}\"\n\n    # Test 2: Check main RSS feed\n    res = client.get(\n        url_for(\"rss.feed\", token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n\n    root = ET.fromstring(res.data)\n    items = root.findall('.//item')\n    assert len(items) >= 1, \"Expected at least 1 item in main RSS feed\"\n\n    # Get categories from first item in main feed\n    categories = [cat.text for cat in items[0].findall('category')]\n\n    print(f\"Found categories in main RSS feed: {categories}\")\n\n    # Should have all three categories\n    assert \"Security\" in categories, f\"Expected 'Security' category in main feed, got: {categories}\"\n    assert \"Python\" in categories, f\"Expected 'Python' category in main feed, got: {categories}\"\n    assert \"Tech News\" in categories, f\"Expected 'Tech News' category in main feed, got: {categories}\"\n\n    # Test 3: Check tag-specific RSS feed (should also have categories)\n    # Get the tag UUID for \"Security\" and verify the tag feed also has categories\n    from .util import get_UUID_for_tag_name\n    security_tag_uuid = get_UUID_for_tag_name(client, name=\"Security\")\n\n    if security_tag_uuid:\n        res = client.get(\n            url_for(\"rss.rss_tag_feed\", tag_uuid=security_tag_uuid, token=rss_token, _external=True),\n            follow_redirects=True\n        )\n        assert res.status_code == 200\n\n        root = ET.fromstring(res.data)\n        items = root.findall('.//item')\n\n        if len(items) >= 1:\n            categories = [cat.text for cat in items[0].findall('category')]\n            print(f\"Found categories in tag RSS feed: {categories}\")\n\n            # Should still have all three categories\n            assert \"Security\" in categories, f\"Expected 'Security' category in tag feed, got: {categories}\"\n            assert \"Python\" in categories, f\"Expected 'Python' category in tag feed, got: {categories}\"\n            assert \"Tech News\" in categories, f\"Expected 'Tech News' category in tag feed, got: {categories}\"\n\n    # Clean up\n    delete_all_watches(client)\n\n\n# RSS <description> should follow Main Settings -> Tag/Group -> Watch in that order of priority if set.\ndef test_rss_single_watch_follow_notification_body(client, live_server, measure_memory_usage, datastore_path):\n    rss_token = extract_rss_token_from_UI(client)\n\n\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n              \"application-fetch_backend\": \"html_requests\",\n              \"application-minutes_between_check\": 180,\n              \"application-notification_body\": 'Boo yeah hello from main settings notification body<br>\\nTitle: {{ watch_title }} changed',\n              \"application-notification_format\": default_notification_format,\n              \"application-rss_template_type\" : 'notification_body',\n              \"application-notification_urls\": \"\",\n\n              },\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n\n    set_original_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, tag=\"RSS-Custom\")\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    set_modified_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n\n    # Request RSS feed for the single watch\n    res = client.get(\n        url_for(\"rss.rss_single_watch\", uuid=uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n\n    # Should return valid RSS\n    assert res.status_code == 200\n    assert b\"<?xml\" in res.data or b\"<rss\" in res.data\n\n    # Check it took the notification body from main settings ####\n    item_description = ET.fromstring(res.data).findall('.//item')[0].findtext('description')\n    assert \"Boo yeah hello from main settings notification body\" in item_description\n    assert \"Title: modified head\" in item_description\n\n\n    ## Edit the tag notification_body, it should cascade up and become the RSS output\n    res = client.post(\n        url_for(\"tags.form_tag_edit_submit\", uuid=\"first\"),\n        data={\"name\": \"rss-custom\",\n              \"notification_body\": 'Hello from the group/tag level'},\n        follow_redirects=True\n    )\n    assert b\"Updated\" in res.data\n    res = client.get(\n        url_for(\"rss.rss_single_watch\", uuid=uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    item_description = ET.fromstring(res.data).findall('.//item')[0].findtext('description')\n    assert 'Hello from the group/tag level' in item_description\n\n    # Override notification body at watch level and check ####\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\"notification_body\": \"RSS body description set from watch level at notification body - {{ watch_title }}\",\n              \"url\": test_url,\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"\n              },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    res = client.get(\n        url_for(\"rss.rss_single_watch\", uuid=uuid, token=rss_token, _external=True),\n        follow_redirects=True\n    )\n    item_description = ET.fromstring(res.data).findall('.//item')[0].findtext('description')\n    assert 'RSS body description set from watch level at notification body - modified head title' in item_description\n    delete_all_watches(client)"
  },
  {
    "path": "changedetectionio/tests/test_scheduler.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom copy import copy\nfrom datetime import datetime, timezone\nfrom zoneinfo import ZoneInfo\nfrom flask import url_for\nfrom .util import  live_server_setup, wait_for_all_checks, extract_UUID_from_client, delete_all_watches\nfrom ..forms import REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT, REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT\n\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\ndef test_check_basic_scheduler_functionality(client, live_server, measure_memory_usage, datastore_path):\n    \n    days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']\n    test_url = url_for('test_random_content_endpoint', _external=True)\n\n    # We use \"Pacific/Kiritimati\" because its the furthest +14 hours, so it might show up more interesting bugs\n    # The rest of the actual functionality should be covered in the unit-test  unit/test_scheduler.py\n    #####################\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-empty_pages_are_a_change\": \"\",\n              \"requests-time_between_check-seconds\": 1,\n              \"application-scheduler_timezone_default\": \"Pacific/Kiritimati\",  # Most Forward Time Zone (UTC+14:00)\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    res = client.get(url_for(\"settings.settings_page\"))\n    assert b'Pacific/Kiritimati' in res.data\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc\n    last_check = copy(live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'])\n    tpl = {\n        \"time_schedule_limit-XXX-start_time\": \"00:00\",\n        \"time_schedule_limit-XXX-duration-hours\": 24,\n        \"time_schedule_limit-XXX-duration-minutes\": 0,\n        \"time_between_check-seconds\": 1,\n        \"time_schedule_limit-XXX-enabled\": '',  # All days are turned off\n        \"time_schedule_limit-enabled\": 'y',  # Scheduler is enabled, all days however are off.\n    }\n\n    scheduler_data = {}\n    for day in days:\n        for key, value in tpl.items():\n            # Replace \"XXX\" with the current day in the key\n            new_key = key.replace(\"XXX\", day)\n            scheduler_data[new_key] = value\n\n    data = {\n        \"url\": test_url,\n        \"fetch_backend\": \"html_requests\",\n        \"time_between_check_use_default\": \"\" # no\n    }\n    data.update(scheduler_data)\n    time.sleep(1)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data=data,\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    res = client.get(url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"))\n    assert b\"Pacific/Kiritimati\" in res.data, \"Should be Pacific/Kiritimati in placeholder data\"\n\n    # \"Edit\" should not trigger a check because it's not enabled in the schedule.\n    time.sleep(2)\n    # \"time_schedule_limit-XXX-enabled\": '',  # All days are turned off, therefor, nothing should happen here..\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] == last_check\n\n    # Enabling today in Kiritimati should work flawless\n    kiritimati_time = datetime.now(timezone.utc).astimezone(ZoneInfo(\"Pacific/Kiritimati\"))\n    kiritimati_time_day_of_week = kiritimati_time.strftime(\"%A\").lower()\n    live_server.app.config['DATASTORE'].data['watching'][uuid][\"time_schedule_limit\"][kiritimati_time_day_of_week][\"enabled\"] = True\n    time.sleep(3)\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] != last_check\n\n    # Cleanup everything\n    delete_all_watches(client)\n\n\ndef test_check_basic_global_scheduler_functionality(client, live_server, measure_memory_usage, datastore_path):\n    \n    days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']\n    test_url = url_for('test_random_content_endpoint', _external=True)\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc\n\n    tpl = {\n        \"requests-time_schedule_limit-XXX-start_time\": \"00:00\",\n        \"requests-time_schedule_limit-XXX-duration-hours\": 24,\n        \"requests-time_schedule_limit-XXX-duration-minutes\": 0,\n        \"requests-time_schedule_limit-XXX-enabled\": '',  # All days are turned off\n        \"requests-time_schedule_limit-enabled\": 'y',  # Scheduler is enabled, all days however are off.\n    }\n\n    scheduler_data = {}\n    for day in days:\n        for key, value in tpl.items():\n            # Replace \"XXX\" with the current day in the key\n            new_key = key.replace(\"XXX\", day)\n            scheduler_data[new_key] = value\n\n    data = {\n        \"application-empty_pages_are_a_change\": \"\",\n        \"application-scheduler_timezone_default\": \"Pacific/Kiritimati\",  # Most Forward Time Zone (UTC+14:00)\n        'application-fetch_backend': \"html_requests\",\n        \"requests-time_between_check-hours\": 0,\n        \"requests-time_between_check-minutes\": 0,\n        \"requests-time_between_check-seconds\": 1,\n    }\n    data.update(scheduler_data)\n\n    #####################\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data=data,\n        follow_redirects=True\n    )\n\n    assert b\"Settings updated.\" in res.data\n\n    res = client.get(url_for(\"settings.settings_page\"))\n    assert b'Pacific/Kiritimati' in res.data\n\n    wait_for_all_checks(client)\n\n    # UI Sanity check\n\n    res = client.get(url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"))\n    assert b\"Pacific/Kiritimati\" in res.data, \"Should be Pacific/Kiritimati in placeholder data\"\n\n    #### HITTING SAVE SHOULD NOT TRIGGER A CHECK\n    last_check = live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked']\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            \"fetch_backend\": \"html_requests\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    time.sleep(2)\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] == last_check\n\n    # Enabling \"today\" in Kiritimati time should make the system check that watch\n    kiritimati_time = datetime.now(timezone.utc).astimezone(ZoneInfo(\"Pacific/Kiritimati\"))\n    kiritimati_time_day_of_week = kiritimati_time.strftime(\"%A\").lower()\n    live_server.app.config['DATASTORE'].data['settings']['requests']['time_schedule_limit'][kiritimati_time_day_of_week][\"enabled\"] = True\n\n    time.sleep(3)\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] != last_check\n\n    # Cleanup everything\n    delete_all_watches(client)\n\n\ndef test_validation_time_interval_field(client, live_server, measure_memory_usage, datastore_path):\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"trigger_text\": 'The golden line',\n              \"url\": test_url,\n              'fetch_backend': \"html_requests\",\n              'filter_text_removed': 'y',\n              \"time_between_check_use_default\": \"\"\n              },\n        follow_redirects=True\n    )\n\n    assert REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT.encode('utf-8') in res.data\n\n    # Now set atleast something\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"trigger_text\": 'The golden line',\n              \"url\": test_url,\n              'fetch_backend': \"html_requests\",\n              \"time_between_check-minutes\": 1,\n              \"time_between_check_use_default\": \"\"\n              },\n        follow_redirects=True\n    )\n\n    assert REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT.encode('utf-8') not in res.data\n\n\n"
  },
  {
    "path": "changedetectionio/tests/test_search.py",
    "content": "from flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup\nimport time\n\n\n\ndef test_basic_search(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    urls = ['https://localhost:12300?first-result=1',\n            'https://localhost:5000?second-result=1'\n            ]\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": \"\\r\\n\".join(urls)},\n        follow_redirects=True\n    )\n\n    assert b\"2 Imported\" in res.data\n\n    # By URL\n    res = client.get(url_for(\"watchlist.index\") + \"?q=first-res\")\n    assert urls[0].encode('utf-8') in res.data\n    assert urls[1].encode('utf-8') not in res.data\n\n    # By Title\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"title\": \"xxx-title\", \"url\": urls[0], \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    res = client.get(url_for(\"watchlist.index\") + \"?q=xxx-title\")\n    assert urls[0].encode('utf-8') in res.data\n    assert urls[1].encode('utf-8') not in res.data\n\n\ndef test_search_in_tag_limit(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    urls = ['https://localhost:12300?first-result=1 tag-one',\n            'https://localhost:5000?second-result=1 tag-two'\n            ]\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": \"\\r\\n\".join(urls)},\n        follow_redirects=True\n    )\n\n    assert b\"2 Imported\" in res.data\n\n    # By URL\n\n    res = client.get(url_for(\"watchlist.index\") + \"?q=first-res\")\n    # Split because of the import tag separation\n    assert urls[0].split(' ')[0].encode('utf-8') in res.data, urls[0].encode('utf-8')\n    assert urls[1].split(' ')[0].encode('utf-8') not in res.data, urls[0].encode('utf-8')\n\n    # By Title\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"title\": \"xxx-title\", \"url\": urls[0].split(' ')[0], \"tags\": urls[0].split(' ')[1], \"headers\": \"\",\n              'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    res = client.get(url_for(\"watchlist.index\") + \"?q=xxx-title\")\n    assert urls[0].split(' ')[0].encode('utf-8') in res.data, urls[0].encode('utf-8')\n    assert urls[1].split(' ')[0].encode('utf-8') not in res.data, urls[0].encode('utf-8')\n\n"
  },
  {
    "path": "changedetectionio/tests/test_security.py",
    "content": "import os\nimport pytest\n\nfrom flask import url_for\n\nfrom changedetectionio.tests.util import set_modified_response\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nfrom .. import strtobool\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n    <head><title>head title</title></head>\n    <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <span class=\"foobar-detection\" style='display:none'></span>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef test_favicon(client, live_server, measure_memory_usage, datastore_path):\n    # Attempt to fetch it, make sure that works\n    SVG_BASE64 = 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxIDEiLz4='\n    uuid = client.application.config.get('DATASTORE').add_watch(url='https://localhost')\n    live_server.app.config['DATASTORE'].data['watching'][uuid].bump_favicon(url=\"favicon-set-type.svg\",\n                                                                            favicon_base_64=SVG_BASE64\n                                                                            )\n\n\n    res = client.get(url_for('static_content', group='favicon', filename=uuid))\n    assert res.status_code == 200\n    assert len(res.data) > 10\n\n    res = client.get(url_for('static_content', group='..', filename='__init__.py'))\n    assert res.status_code != 200\n\n\n    res = client.get(url_for('static_content', group='.', filename='../__init__.py'))\n    assert res.status_code != 200\n\n    # Traverse by filename protection\n    res = client.get(url_for('static_content', group='js', filename='../styles/styles.css'))\n    assert res.status_code != 200\n\ndef test_bad_access(client, live_server, measure_memory_usage, datastore_path):\n\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": 'https://localhost'},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # Attempt to add a body with a GET method\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n              \"url\": 'javascript:alert(document.domain)',\n              \"tags\": \"\",\n              \"method\": \"GET\",\n              \"fetch_backend\": \"html_requests\",\n              \"body\": \"\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b'Watch protocol is not permitted or invalid URL format' in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": '            javascript:alert(123)', \"tags\": ''},\n        follow_redirects=True\n    )\n\n    assert b'Watch protocol is not permitted or invalid URL format' in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": '%20%20%20javascript:alert(123)%20%20', \"tags\": ''},\n        follow_redirects=True\n    )\n\n    assert b'Watch protocol is not permitted or invalid URL format' in res.data\n\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": ' source:javascript:alert(document.domain)', \"tags\": ''},\n        follow_redirects=True\n    )\n\n    assert b'Watch protocol is not permitted or invalid URL format' in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": 'https://i-wanna-xss-you.com?hereis=<script>alert(1)</script>', \"tags\": ''},\n        follow_redirects=True\n    )\n\n    assert b'Watch protocol is not permitted or invalid URL format' in res.data\n\ndef _runner_test_various_file_slash(client, file_uri):\n\n    client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": file_uri, \"tags\": ''},\n        follow_redirects=True\n    )\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    substrings = [b\"URLs with hostname components are not permitted\", b\"No connection adapters were found for\"]\n\n\n    # If it is enabled at test time\n    if strtobool(os.getenv('ALLOW_FILE_URI', 'false')):\n        if file_uri.startswith('file:///'):\n            # This one should be the full qualified path to the file and should get the contents of this file\n            res = client.get(\n                url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n                follow_redirects=True\n            )\n            assert b'_runner_test_various_file_slash' in res.data\n        else:\n            # This will give some error from requests or if it went to chrome, will give some other error :-)\n            assert any(s in res.data for s in substrings)\n\n    delete_all_watches(client)\n\ndef test_file_slash_access(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    # file: is NOT permitted by default, so it will be caught by ALLOW_FILE_URI check\n\n    test_file_path = os.path.abspath(__file__)\n    _runner_test_various_file_slash(client, file_uri=f\"file://{test_file_path}\")\n#    _runner_test_various_file_slash(client, file_uri=f\"file:/{test_file_path}\")\n#    _runner_test_various_file_slash(client, file_uri=f\"file:{test_file_path}\") # CVE-2024-56509\n\ndef test_xss(client, live_server, measure_memory_usage, datastore_path):\n    \n    from changedetectionio.notification import (\n        default_notification_format\n    )\n    # the template helpers were named .jinja which meant they were not having jinja2 autoescape enabled.\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-notification_urls\": '\"><img src=x onerror=alert(document.domain)>',\n              \"application-notification_title\": '\"><img src=x onerror=alert(document.domain)>',\n              \"application-notification_body\": '\"><img src=x onerror=alert(document.domain)>',\n              \"application-notification_format\": default_notification_format,\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n\n    assert b\"<img src=x onerror=alert(\" not in res.data\n    assert b\"&lt;img\" in res.data\n\n    # Check that even forcing an update directly still doesnt get to the frontend\n    set_original_response(datastore_path=datastore_path)\n    XSS_HACK = 'javascript:alert(document.domain)'\n    uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_endpoint', _external=True))\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    set_modified_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    live_server.app.config['DATASTORE'].data['watching'][uuid]['url']=XSS_HACK\n\n\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=uuid))\n    assert XSS_HACK.encode('utf-8') not in res.data and res.status_code == 200\n    client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=uuid))\n    assert XSS_HACK.encode('utf-8') not in res.data and res.status_code == 200\n    res = client.get(url_for(\"watchlist.index\"))\n    assert XSS_HACK.encode('utf-8') not in res.data and res.status_code == 200\n\n\ndef test_xss_watch_last_error(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n\n    wait_for_all_checks(client)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"include_filters\": '<a href=\"https://foobar\"></a><script>alert(123);</script>',\n            \"url\": url_for('test_endpoint', _external=True),\n            'fetch_backend': \"html_requests\",\n            \"time_between_check_use_default\": \"y\"\n        },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b\"<script>alert(123);</script>\" not in res.data  # this text should be there\n    assert b'&lt;a href=&#34;https://foobar&#34;&gt;&lt;/a&gt;&lt;script&gt;alert(123);&lt;/script&gt;' in res.data\n    assert b\"https://foobar\" in res.data # this text should be there\n\n\ndef test_login_redirect_safe_urls(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that safe redirect URLs work correctly in login flow.\n    This verifies the fix for open redirect vulnerabilities while maintaining\n    legitimate redirect functionality for both authenticated and unauthenticated users.\n    \"\"\"\n\n    # Test 1: Accessing /login?redirect=/settings when not logged in\n    # Should show the login form with redirect parameter preserved\n    res = client.get(\n        url_for(\"login\", redirect=\"/settings\"),\n        follow_redirects=False\n    )\n    # Should show login form\n    assert res.status_code == 200\n    # Check that the redirect is preserved in the hidden form field\n    assert b'name=\"redirect\"' in res.data\n\n    # Test 2: Valid internal redirect with query parameters\n    res = client.get(\n        url_for(\"login\", redirect=\"/settings?tab=notifications\"),\n        follow_redirects=False\n    )\n    assert res.status_code == 200\n    # Check that the redirect is preserved\n    assert b'value=\"/settings?tab=notifications\"' in res.data\n\n    # Test 3: Malicious external URL should be blocked and default to watchlist\n    res = client.get(\n        url_for(\"login\", redirect=\"https://evil.com/phishing\"),\n        follow_redirects=False\n    )\n    # Should show login form\n    assert res.status_code == 200\n    # The redirect parameter in the form should NOT contain the evil URL\n    # Check the actual input value, not just anywhere in the page\n    assert b'value=\"https://evil.com' not in res.data\n    assert b'value=\"/evil.com' not in res.data\n    assert b'name=\"redirect\"' in res.data\n\n    # Test 4: Double-slash attack should be blocked\n    res = client.get(\n        url_for(\"login\", redirect=\"//evil.com\"),\n        follow_redirects=False\n    )\n    assert res.status_code == 200\n    # Should not have the malicious URL in the redirect input value\n    assert b'value=\"//evil.com\"' not in res.data\n\n    # Test 5: Protocol handler exploit should be blocked\n    res = client.get(\n        url_for(\"login\", redirect=\"javascript:alert(document.domain)\"),\n        follow_redirects=False\n    )\n    assert res.status_code == 200\n    # Should not have javascript: in the redirect input value\n    assert b'value=\"javascript:' not in res.data\n\n    # Test 6: At-symbol obfuscation attack should be blocked\n    res = client.get(\n        url_for(\"login\", redirect=\"//@evil.com\"),\n        follow_redirects=False\n    )\n    assert res.status_code == 200\n    # Should not have the malicious URL in the redirect input value\n    assert b'value=\"//@evil.com\"' not in res.data\n\n    # Test 7: Multiple slashes attack should be blocked\n    res = client.get(\n        url_for(\"login\", redirect=\"////evil.com\"),\n        follow_redirects=False\n    )\n    assert res.status_code == 200\n    # Should not have the malicious URL in the redirect input value\n    assert b'value=\"////evil.com\"' not in res.data\n\n\ndef test_login_redirect_with_password(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that redirect functionality works correctly when a password is set.\n    This ensures that notifications can always link to /login and users will\n    be redirected to the correct page after authentication.\n    \"\"\"\n\n    # Set a password\n    from changedetectionio import store\n    import base64\n    import hashlib\n\n    # Generate a test password\n    password = \"test123\"\n    salt = os.urandom(32)\n    key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)\n    salted_pass = base64.b64encode(salt + key).decode('ascii')\n\n    # Set the password in the datastore\n    client.application.config['DATASTORE'].data['settings']['application']['password'] = salted_pass\n\n    # Test 1: Try to access /login?redirect=/settings without being logged in\n    # Should show login form and preserve redirect parameter\n    res = client.get(\n        url_for(\"login\", redirect=\"/settings\"),\n        follow_redirects=False\n    )\n    assert res.status_code == 200\n    assert b\"Password\" in res.data\n    # Check that redirect parameter is preserved in the form\n    assert b'name=\"redirect\"' in res.data\n    assert b'value=\"/settings\"' in res.data\n\n    # Test 2: Submit correct password with redirect parameter\n    # Should redirect to /settings after successful login\n    res = client.post(\n        url_for(\"login\"),\n        data={\"password\": password, \"redirect\": \"/settings\"},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    # Should be on settings page\n    assert b\"Settings\" in res.data or b\"settings\" in res.data\n\n    # Test 3: Now that we're logged in, accessing /login?redirect=/settings\n    # should redirect immediately without showing login form\n    res = client.get(\n        url_for(\"login\", redirect=\"/\"),\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b\"Already logged in\" in res.data\n\n    # Test 4: Malicious redirect should be blocked even with correct password\n    res = client.post(\n        url_for(\"login\"),\n        data={\"password\": password, \"redirect\": \"https://evil.com\"},\n        follow_redirects=True\n    )\n    # Should redirect to watchlist index instead of evil.com\n    assert b\"evil.com\" not in res.data\n\n    # Logout for cleanup\n    client.get(url_for(\"logout\"))\n\n    # Test 5: Incorrect password with redirect should stay on login page\n    res = client.post(\n        url_for(\"login\"),\n        data={\"password\": \"wrongpassword\", \"redirect\": \"/settings\"},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n    assert b\"Incorrect password\" in res.data or b\"password\" in res.data\n\n    # Clear the password\n    del client.application.config['DATASTORE'].data['settings']['application']['password']\n\n\ndef test_login_redirect_from_protected_page(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test the complete redirect flow: accessing a protected page while logged out\n    should redirect to login with the page URL, then redirect back after login.\n    This is the real-world scenario where users try to access /edit/uuid or /settings\n    and need to login first.\n    \"\"\"\n    import base64\n    import hashlib\n\n    # Add a watch first\n    set_original_response(datastore_path=datastore_path)\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)},\n        follow_redirects=True\n    )\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # Set a password\n    password = \"test123\"\n    salt = os.urandom(32)\n    key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)\n    salted_pass = base64.b64encode(salt + key).decode('ascii')\n    client.application.config['DATASTORE'].data['settings']['application']['password'] = salted_pass\n\n    # Logout to ensure we're not authenticated\n    client.get(url_for(\"logout\"))\n\n    # Try to access a protected page (edit page for first watch)\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        follow_redirects=False\n    )\n\n    # Should redirect to login with the edit page as redirect parameter\n    assert res.status_code in [302, 303]\n    assert '/login' in res.location\n    assert 'redirect=' in res.location or 'redirect=%2F' in res.location\n\n    # Follow the redirect to login page\n    res = client.get(res.location, follow_redirects=False)\n    assert res.status_code == 200\n    assert b'Password' in res.data\n\n    # The redirect parameter should be preserved in the login form\n    # It should contain the edit page URL\n    assert b'name=\"redirect\"' in res.data\n    assert b'value=\"/edit/first\"' in res.data or b'value=\"%2Fedit%2Ffirst\"' in res.data\n\n    # Now login with correct password and the redirect parameter\n    res = client.post(\n        url_for(\"login\"),\n        data={\"password\": password, \"redirect\": \"/edit/first\"},\n        follow_redirects=False\n    )\n\n    # Should redirect to the edit page\n    assert res.status_code in [302, 303]\n    assert '/edit/first' in res.location\n\n    # Follow the redirect to verify we're on the edit page\n    res = client.get(res.location, follow_redirects=True)\n    assert res.status_code == 200\n    # Should see edit page content\n    assert b'Edit' in res.data or b'Watching' in res.data\n\n    # Cleanup\n    client.get(url_for(\"logout\"))\n    del client.application.config['DATASTORE'].data['settings']['application']['password']\n\n\ndef test_logout_with_redirect(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that logout preserves the current page URL, so after re-login\n    the user returns to where they were before logging out.\n    Example: User is on /edit/uuid, clicks logout, then logs back in and\n    returns to /edit/uuid.\n    \"\"\"\n    import base64\n    import hashlib\n\n    # Set a password and login\n    password = \"test123\"\n    salt = os.urandom(32)\n    key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)\n    salted_pass = base64.b64encode(salt + key).decode('ascii')\n    client.application.config['DATASTORE'].data['settings']['application']['password'] = salted_pass\n\n    # Login\n    res = client.post(\n        url_for(\"login\"),\n        data={\"password\": password},\n        follow_redirects=True\n    )\n    assert res.status_code == 200\n\n    # Now logout with a redirect parameter (simulating logout from /settings)\n    res = client.get(\n        url_for(\"logout\", redirect=\"/settings\"),\n        follow_redirects=False\n    )\n\n    # Should redirect to login with the redirect parameter\n    assert res.status_code in [302, 303]\n    assert '/login' in res.location\n    assert 'redirect=' in res.location or 'redirect=%2F' in res.location\n\n    # Follow the redirect to login page\n    res = client.get(res.location, follow_redirects=False)\n    assert res.status_code == 200\n    assert b'Password' in res.data\n    # The redirect parameter should be preserved\n    assert b'value=\"/settings\"' in res.data or b'value=\"%2Fsettings\"' in res.data\n\n    # Login again with the redirect\n    res = client.post(\n        url_for(\"login\"),\n        data={\"password\": password, \"redirect\": \"/settings\"},\n        follow_redirects=False\n    )\n\n    # Should redirect back to settings\n    assert res.status_code in [302, 303]\n    assert '/settings' in res.location or 'settings' in res.location\n\n    # Cleanup\n    del client.application.config['DATASTORE'].data['settings']['application']['password']\n\n\ndef test_static_directory_traversal(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that the static file serving route properly blocks directory traversal attempts.\n    This tests the fix for GHSA-9jj8-v89v-xjvw (CVE pending).\n\n    The vulnerability was in /static/<group>/<filename> where the sanitization regex\n    allowed dots, enabling \"../\" traversal to read application source files.\n\n    The fix changed the regex from r'[^\\w.-]+' to r'[^a-z0-9_]+' which blocks dots.\n    \"\"\"\n\n    # Test 1: Direct .. traversal attempt (URL-encoded)\n    res = client.get(\n        \"/static/%2e%2e/flask_app.py\",\n        follow_redirects=False\n    )\n    # Should be blocked (404 or 403)\n    assert res.status_code in [404, 403], f\"Expected 404/403, got {res.status_code}\"\n    # Should NOT contain application source code\n    assert b\"def static_content\" not in res.data\n    assert b\"changedetection_app\" not in res.data\n\n    # Test 2: Direct .. traversal attempt (unencoded)\n    res = client.get(\n        \"/static/../flask_app.py\",\n        follow_redirects=False\n    )\n    assert res.status_code in [404, 403], f\"Expected 404/403, got {res.status_code}\"\n    assert b\"def static_content\" not in res.data\n\n    # Test 3: Multiple dots traversal\n    res = client.get(\n        \"/static/..../flask_app.py\",\n        follow_redirects=False\n    )\n    assert res.status_code in [404, 403], f\"Expected 404/403, got {res.status_code}\"\n    assert b\"def static_content\" not in res.data\n\n    # Test 4: Try to access other application files\n    for filename in [\"__init__.py\", \"datastore.py\", \"store.py\"]:\n        res = client.get(\n            f\"/static/%2e%2e/{filename}\",\n            follow_redirects=False\n        )\n        assert res.status_code in [404, 403], f\"File {filename} should be blocked\"\n        # Should not contain Python code indicators\n        assert b\"import\" not in res.data or b\"# Test\" in res.data  # Allow \"1 Imported\" etc\n\n    # Test 5: Verify legitimate static files still work\n    # Note: We can't test actual files without knowing what exists,\n    # but we can verify the sanitization doesn't break valid groups\n    res = client.get(\n        \"/static/images/test.png\",  # Will 404 if file doesn't exist, but won't traverse\n        follow_redirects=False\n    )\n    # Should get 404 (file not found) not 403 (blocked)\n    # This confirms the group name \"images\" is valid\n    assert res.status_code == 404\n\n    # Test 6: Ensure hyphens and dots are blocked in group names\n    res = client.get(\n        \"/static/../../../etc/passwd\",\n        follow_redirects=False\n    )\n    assert res.status_code in [404, 403]\n    assert b\"root:\" not in res.data\n\n    # Test 7: Test that underscores still work (they're allowed)\n    res = client.get(\n        \"/static/visual_selector_data/test.json\",\n        follow_redirects=False\n    )\n    # visual_selector_data is a real group, but requires auth\n    # Should get 403 (not authenticated) or 404 (file not found), not a path traversal\n    assert res.status_code in [403, 404]\n\n\ndef test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path):\n    \"\"\"\n    SSRF protection: IANA-reserved/private IP addresses are blocked at fetch-time, not add-time.\n\n    Watches targeting private/reserved IPs can be *added* freely; the block happens when the\n    fetcher actually tries to reach the URL (via validate_iana_url() in call_browser()).\n\n    Covers:\n    1. is_private_hostname() correctly classifies all reserved ranges\n    2. is_safe_valid_url() ALLOWS private-IP URLs at add-time (IANA check moved to fetch-time)\n    3. ALLOW_IANA_RESTRICTED_ADDRESSES has no effect on add-time; it only controls fetch-time\n    4. UI form accepts private-IP URLs at add-time without error\n    5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch)\n    6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass)\n\n    conftest.py sets ALLOW_IANA_RESTRICTED_ADDRESSES=true globally so the test\n    server (localhost) keeps working for all other tests.  monkeypatch temporarily\n    overrides it to 'false' here, and is automatically restored after the test.\n    \"\"\"\n    from unittest.mock import patch, MagicMock\n    from changedetectionio.validate_url import is_safe_valid_url, is_private_hostname\n\n    monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')\n\n    # ------------------------------------------------------------------\n    # 1. is_private_hostname() — unit tests across all reserved ranges\n    # ------------------------------------------------------------------\n    private_hosts = [\n        '127.0.0.1',          # loopback\n        '10.0.0.1',           # RFC 1918\n        '172.16.0.1',         # RFC 1918\n        '192.168.1.1',        # RFC 1918\n        '169.254.169.254',    # link-local / AWS metadata endpoint\n        '::1',                # IPv6 loopback\n        'fc00::1',            # IPv6 unique local\n        'fe80::1',            # IPv6 link-local\n    ]\n    for host in private_hosts:\n        assert is_private_hostname(host), f\"{host} should be identified as private/reserved\"\n\n    for host in ['8.8.8.8', '1.1.1.1']:\n        assert not is_private_hostname(host), f\"{host} should be identified as public\"\n\n    # ------------------------------------------------------------------\n    # 2. is_safe_valid_url() ALLOWS private-IP URLs at add-time\n    #    IANA check is no longer done here — it moved to fetch-time validate_iana_url()\n    # ------------------------------------------------------------------\n    private_ip_urls = [\n        'http://127.0.0.1/',\n        'http://10.0.0.1/',\n        'http://172.16.0.1/',\n        'http://192.168.1.1/',\n        'http://169.254.169.254/',\n        'http://169.254.169.254/latest/meta-data/iam/security-credentials/',\n        'http://[::1]/',\n        'http://[fc00::1]/',\n        'http://[fe80::1]/',\n    ]\n    for url in private_ip_urls:\n        assert is_safe_valid_url(url), f\"{url} should be allowed by is_safe_valid_url (IANA check is at fetch-time)\"\n\n    # ------------------------------------------------------------------\n    # 3. ALLOW_IANA_RESTRICTED_ADDRESSES does not affect add-time validation\n    #    It only controls fetch-time blocking inside validate_iana_url()\n    # ------------------------------------------------------------------\n    monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')\n    assert is_safe_valid_url('http://127.0.0.1/'), \\\n        \"Private IP should be allowed at add-time regardless of ALLOW_IANA_RESTRICTED_ADDRESSES\"\n\n    monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')\n    assert is_safe_valid_url('http://127.0.0.1/'), \\\n        \"Private IP should be allowed at add-time regardless of ALLOW_IANA_RESTRICTED_ADDRESSES\"\n\n    # ------------------------------------------------------------------\n    # 4. UI form accepts private-IP URLs at add-time\n    #    The watch is created; the SSRF block fires later at fetch-time\n    # ------------------------------------------------------------------\n    for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']:\n        res = client.post(\n            url_for('ui.ui_views.form_quick_watch_add'),\n            data={'url': url, 'tags': ''},\n            follow_redirects=True\n        )\n        assert b'Watch protocol is not permitted or invalid URL format' not in res.data, \\\n            f\"UI should accept {url} at add-time (SSRF is blocked at fetch-time)\"\n\n    # ------------------------------------------------------------------\n    # 5. Fetch-time DNS-rebinding check in the requests fetcher\n    #    Simulates: URL passed add-time validation with a public IP, but\n    #    by fetch time DNS has been rebound to a private IP.\n    # ------------------------------------------------------------------\n    from changedetectionio.content_fetchers.requests import fetcher as RequestsFetcher\n\n    f = RequestsFetcher()\n\n    with patch('changedetectionio.content_fetchers.requests.is_private_hostname', return_value=True):\n        with pytest.raises(Exception, match='private/reserved'):\n            f._run_sync(\n                url='http://example.com/',\n                timeout=5,\n                request_headers={},\n                request_body=None,\n                request_method='GET',\n            )\n\n    # ------------------------------------------------------------------\n    # 6. Redirect-to-private-IP blocked (open-redirect SSRF bypass)\n    #    Public host returns a 302 pointing at an IANA-reserved address.\n    # ------------------------------------------------------------------\n    mock_redirect = MagicMock()\n    mock_redirect.is_redirect = True\n    mock_redirect.status_code = 302\n    mock_redirect.headers = {'Location': 'http://169.254.169.254/latest/meta-data/'}\n\n    def _private_only_for_redirect(hostname):\n        # Initial host is \"public\"; the redirect target is private\n        return hostname in {'169.254.169.254', '10.0.0.1', '172.16.0.1',\n                            '192.168.0.1', '127.0.0.1', '::1'}\n\n    with patch('changedetectionio.content_fetchers.requests.is_private_hostname',\n               side_effect=_private_only_for_redirect):\n        with patch('requests.Session.request', return_value=mock_redirect):\n            with pytest.raises(Exception, match='Redirect blocked'):\n                f._run_sync(\n                    url='http://example.com/',\n                    timeout=5,\n                    request_headers={},\n                    request_body=None,\n                    request_method='GET',\n                )\n\n\ndef test_unresolvable_hostname_is_allowed(client, live_server, monkeypatch):\n    \"\"\"\n    Unresolvable hostnames must NOT be blocked at add-time when ALLOW_IANA_RESTRICTED_ADDRESSES=false.\n\n    DNS failure (gaierror) at add-time does not mean the URL resolves to a private IP —\n    the domain may simply be offline or not yet live. Blocking it would be a false positive.\n    The real DNS-rebinding protection happens at fetch-time in call_browser().\n    \"\"\"\n    from changedetectionio.validate_url import is_safe_valid_url\n\n    monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')\n\n    url = 'http://this-host-does-not-exist-xyz987.invalid/some/path'\n\n    # Should pass URL validation despite being unresolvable\n    assert is_safe_valid_url(url), \\\n        \"Unresolvable hostname should pass is_safe_valid_url — DNS failure is not a private-IP signal\"\n\n    # Should be accepted via the UI form and appear in the watch list\n    res = client.post(\n        url_for('ui.ui_views.form_quick_watch_add'),\n        data={'url': url, 'tags': ''},\n        follow_redirects=True\n    )\n    assert b'Watch protocol is not permitted or invalid URL format' not in res.data, \\\n        \"UI should not reject a URL just because its hostname is unresolvable\"\n\n    res = client.get(url_for('watchlist.index'))\n    assert b'this-host-does-not-exist-xyz987.invalid' in res.data, \\\n        \"Unresolvable hostname watch should appear in the watch overview list\"\n"
  },
  {
    "path": "changedetectionio/tests/test_settings_tag_force_reprocess.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest that changing global settings or tag configurations forces reprocessing.\n\nWhen settings or tag configurations change, all affected watches need to\nreprocess even if their content hasn't changed, because configuration affects\nthe processing result.\n\"\"\"\n\nimport os\nimport time\nfrom flask import url_for\nfrom .util import wait_for_all_checks\n\n\ndef test_settings_change_forces_reprocess(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that changing global settings clears all checksums to force reprocessing.\n    \"\"\"\n\n    # Setup test content\n    test_html = \"\"\"<html>\n     <body>\n     <p>Test content that stays the same</p>\n     </body>\n     </html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_html)\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Add two watches\n    datastore = client.application.config.get('DATASTORE')\n    uuid1 = datastore.add_watch(url=test_url, extras={'title': 'Watch 1'})\n    uuid2 = datastore.add_watch(url=test_url, extras={'title': 'Watch 2'})\n\n    # Unpause watches\n    datastore.data['watching'][uuid1]['paused'] = False\n    datastore.data['watching'][uuid2]['paused'] = False\n\n    # First check - establishes baseline\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Verify checksum files were created\n    checksum1 = os.path.join(datastore_path, uuid1, 'last-checksum.txt')\n    checksum2 = os.path.join(datastore_path, uuid2, 'last-checksum.txt')\n    assert os.path.isfile(checksum1), \"First check should create checksum file for watch 1\"\n    assert os.path.isfile(checksum2), \"First check should create checksum file for watch 2\"\n\n    # Change global settings (any setting will do)\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n            \"application-empty_pages_are_a_change\": \"\",\n            \"requests-time_between_check-minutes\": 180,\n            'application-fetch_backend': \"html_requests\"\n        },\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Give it a moment to process\n    time.sleep(0.5)\n\n    # Verify ALL checksum files were deleted\n    assert not os.path.isfile(checksum1), \"Settings change should delete checksum for watch 1\"\n    assert not os.path.isfile(checksum2), \"Settings change should delete checksum for watch 2\"\n\n    # Next check should reprocess (not skip) and recreate checksums\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Verify checksum files were recreated\n    assert os.path.isfile(checksum1), \"Reprocessing should recreate checksum file for watch 1\"\n    assert os.path.isfile(checksum2), \"Reprocessing should recreate checksum file for watch 2\"\n\n    print(\"✓ Settings change forces reprocessing of all watches\")\n\n\ndef test_tag_change_forces_reprocess(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that changing a tag configuration clears checksums only for watches with that tag.\n    \"\"\"\n\n    # Setup test content\n    test_html = \"\"\"<html>\n     <body>\n     <p>Test content that stays the same</p>\n     </body>\n     </html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_html)\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create a tag\n    datastore = client.application.config.get('DATASTORE')\n    tag_uuid = datastore.add_tag('Test Tag')\n\n    # Add watches - one with tag, one without\n    uuid_with_tag = datastore.add_watch(url=test_url, extras={'title': 'Watch With Tag', 'tags': [tag_uuid]})\n    uuid_without_tag = datastore.add_watch(url=test_url, extras={'title': 'Watch Without Tag'})\n\n    # Unpause watches\n    datastore.data['watching'][uuid_with_tag]['paused'] = False\n    datastore.data['watching'][uuid_without_tag]['paused'] = False\n\n    # First check - establishes baseline\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Verify checksum files were created\n    checksum_with = os.path.join(datastore_path, uuid_with_tag, 'last-checksum.txt')\n    checksum_without = os.path.join(datastore_path, uuid_without_tag, 'last-checksum.txt')\n    assert os.path.isfile(checksum_with), \"First check should create checksum for tagged watch\"\n    assert os.path.isfile(checksum_without), \"First check should create checksum for untagged watch\"\n\n    # Edit the tag (change notification_muted as an example)\n    tag = datastore.data['settings']['application']['tags'][tag_uuid]\n    res = client.post(\n        url_for(\"tags.form_tag_edit_submit\", uuid=tag_uuid),\n        data={\n            'title': 'Test Tag',\n            'notification_muted': 'y',\n            'overrides_watch': 'n'\n        },\n        follow_redirects=True\n    )\n    assert b\"Updated\" in res.data\n\n    # Give it a moment to process\n    time.sleep(0.5)\n\n    # Verify ONLY the tagged watch's checksum was deleted\n    assert not os.path.isfile(checksum_with), \"Tag change should delete checksum for watch WITH tag\"\n    assert os.path.isfile(checksum_without), \"Tag change should NOT delete checksum for watch WITHOUT tag\"\n\n    # Next check should reprocess tagged watch and recreate its checksum\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Verify tagged watch's checksum was recreated\n    assert os.path.isfile(checksum_with), \"Reprocessing should recreate checksum for tagged watch\"\n    assert os.path.isfile(checksum_without), \"Untagged watch should still have its checksum\"\n\n    print(\"✓ Tag change forces reprocessing only for watches with that tag\")\n\n\ndef test_tag_change_via_api_forces_reprocess(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that updating a tag via API also clears checksums for affected watches.\n    \"\"\"\n\n    # Setup test content\n    test_html = \"\"\"<html>\n     <body>\n     <p>Test content</p>\n     </body>\n     </html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_html)\n\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Create a tag\n    datastore = client.application.config.get('DATASTORE')\n    tag_uuid = datastore.add_tag('API Test Tag')\n\n    # Add watch with tag\n    uuid_with_tag = datastore.add_watch(url=test_url, extras={'title': 'API Watch'})\n    datastore.data['watching'][uuid_with_tag]['paused'] = False\n    datastore.data['watching'][uuid_with_tag]['tags'] = [tag_uuid]\n    datastore.data['watching'][uuid_with_tag].commit()\n\n    # First check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Verify checksum exists\n    checksum_file = os.path.join(datastore_path, uuid_with_tag, 'last-checksum.txt')\n    assert os.path.isfile(checksum_file), \"First check should create checksum file\"\n\n    # Update tag via API\n    res = client.put(\n        f'/api/v1/tag/{tag_uuid}',\n        json={'notification_muted': True},\n        headers={'x-api-key': datastore.data['settings']['application']['api_access_token']}\n    )\n    assert res.status_code == 200, f\"API call failed with status {res.status_code}: {res.data}\"\n\n    # Give it more time for async operations\n    time.sleep(1.0)\n\n    # Debug: Check if checksum still exists\n    if os.path.isfile(checksum_file):\n        # Read checksum to see if it changed\n        with open(checksum_file, 'r') as f:\n            checksum_content = f.read()\n            print(f\"Checksum still exists: {checksum_content}\")\n\n    # Verify checksum was deleted\n    assert not os.path.isfile(checksum_file), \"API tag update should delete checksum\"\n\n    print(\"✓ Tag update via API forces reprocessing\")\n"
  },
  {
    "path": "changedetectionio/tests/test_share_watch.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom urllib.request import urlopen\nfrom .util import set_original_response, set_modified_response, live_server_setup, delete_all_watches\nimport re\n\ndef test_share_watch(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    test_url = url_for('test_endpoint', _external=True)\n    include_filters = \".nice-filter\"\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\"include_filters\": include_filters, \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n    )\n    assert bytes(include_filters.encode('utf-8')) in res.data\n\n    # click share the link\n    res = client.get(\n        url_for(\"ui.form_share_put_watch\", uuid=uuid),\n        follow_redirects=True\n    )\n\n    assert b\"Share this link:\" in res.data\n    assert b\"https://changedetection.io/share/\" in res.data\n\n    html = res.data.decode()\n    share_link_search = re.search('<span id=\"share-link\">(.*)</span>', html, re.IGNORECASE)\n    assert share_link_search\n\n    # Now delete what we have, we will try to re-import it\n    # Cleanup everything\n    delete_all_watches(client)\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": share_link_search.group(1)},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n\n    # Now hit edit, we should see what we expect\n    # that the import fetched the meta-data\n    uuids = list(client.application.config.get('DATASTORE').data['watching'])\n    assert uuids, \"It saved/imported and created a new URL from the share\"\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuids[0]),\n    )\n    assert bytes(include_filters.encode('utf-8')) in res.data\n\n    # Check it saved the URL\n    res = client.get(url_for(\"watchlist.index\"))\n    assert bytes(test_url.encode('utf-8')) in res.data\n\n    delete_all_watches(client)"
  },
  {
    "path": "changedetectionio/tests/test_source.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom urllib.request import urlopen\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks\nfrom ..diff import ADDED_STYLE\n\n\ndef test_check_basic_change_detection_functionality_source(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n    test_url = 'source:'+url_for('test_endpoint', _external=True)\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    #####################\n\n    # Check HTML conversion detected and workd\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    # Check this class DOES appear (that we didnt see the actual source)\n    assert b'foobar-detection' in res.data\n\n    # Make a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Force recheck\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n\n    # Now something should be ready, indicated by having a 'has-unread-changes' class\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    # With diff-match-patch, HTML tags are properly tokenized and excluded from diff spans\n    # Only \"modified\" is shown as added, while <head> and <title> tags remain unchanged\n    assert b'aria-label=\"Changed into\" title=\"Changed into\">' in res.data\n    assert b'&lt;title&gt;modified head title'\n\n# `subtractive_selectors` should still work in `source:` type requests\ndef test_check_ignore_elements(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n    time.sleep(1)\n    test_url = 'source:'+url_for('test_endpoint', _external=True)\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    #####################\n    # We want <span> and <p> ONLY, but ignore span with .foobar-detection\n\n    client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": 'span,p', \"url\": test_url, \"tags\": \"\", \"subtractive_selectors\": \".foobar-detection\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b'foobar-detection' not in res.data\n    assert b'&lt;br' not in res.data\n    assert b'&lt;p' in res.data\n"
  },
  {
    "path": "changedetectionio/tests/test_trigger.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\nimport os\n\n\ndef set_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     and more<br>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef set_modified_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some NEW nice initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     and more<br>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef set_modified_with_trigger_text_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some NEW nice initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     Add to cart\n     <br>\n     So let's see what happens.  <br>\n     and more<br>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef test_trigger_functionality(client, live_server, measure_memory_usage, datastore_path):\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n    trigger_text = \"Add to cart\"\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"trigger_text\": trigger_text,\n              \"ignore_text\": \"and more\",\n              \"url\": test_url,\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    wait_for_all_checks(client)\n    # Check it saved\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n    )\n    assert bytes(trigger_text.encode('utf-8')) in res.data\n\n\n    \n    # so that we set the state to 'has-unread-changes' after all the edits\n    client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n    assert b'/test-endpoint' in res.data\n\n    #  Make a change\n    set_modified_original_ignore_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # Now set the content which contains the trigger text\n    set_modified_with_trigger_text_response(datastore_path=datastore_path)\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    # https://github.com/dgtlmoon/changedetection.io/issues/616\n    # Apparently the actual snapshot that contains the trigger never shows\n    res = client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n    assert b'Add to cart' in res.data\n\n    # Check the preview/highlighter, we should be able to see what we triggered on, but it should be highlighted\n    res = client.get(url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"))\n    assert b'ignored_line_numbers = [8]' in res.data\n\n    # We should be able to see what we triggered on\n    # The JS highlighter should tell us which lines (also used in the live-preview)\n    assert b'const triggered_line_numbers = [6]' in res.data\n    assert b'Add to cart' in res.data\n\n"
  },
  {
    "path": "changedetectionio/tests/test_trigger_regex.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nimport os\n\n\ndef set_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n\ndef test_trigger_regex_functionality(client, live_server, measure_memory_usage, datastore_path):\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (just a new one shouldnt have anything)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    ### test regex\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"trigger_text\": '/something \\d{3}/',\n              \"url\": test_url,\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    wait_for_all_checks(client)\n    # so that we set the state to 'has-unread-changes' after all the edits\n    client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"some new noise\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # It should report nothing found (nothing should match the regex)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"regex test123<br>\\nsomething 123\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n    # Cleanup everything\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_trigger_regex_with_filter.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\n\nfrom .util import live_server_setup, delete_all_watches, wait_for_all_checks\nimport os\n\n\ndef set_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n\ndef test_trigger_regex_functionality_with_filter(client, live_server, measure_memory_usage, datastore_path):\n\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Give the endpoint time to spin up\n    time.sleep(1)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    ### test regex with filter\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"trigger_text\": \"/cool.stuff/\",\n              \"url\": test_url,\n              \"include_filters\": '#in-here',\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n\n    # Check that we have the expected text.. but it's not in the css filter we want\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html>some new noise with cool stuff2 ok</html>\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    # It should report nothing found (nothing should match the regex and filter)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # now this should trigger something\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"<html>some new noise with <span id=in-here>cool stuff6</span> ok</html>\")\n\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n\n# Cleanup everything\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_ui.py",
    "content": "#!/usr/bin/env python3\n\nfrom flask import url_for\nfrom .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, delete_all_watches\nfrom ..forms import REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT, REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT\n\n\ndef test_recheck_time_field_validation_global_settings(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Tests that the global settings time field has atleast one value for week/day/hours/minute/seconds etc entered\n    class globalSettingsRequestForm(Form):\n        time_between_check = RequiredFormField(TimeBetweenCheckForm)\n    \"\"\"\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\n              \"requests-time_between_check-weeks\": '',\n              \"requests-time_between_check-days\": '',\n              \"requests-time_between_check-hours\": '',\n              \"requests-time_between_check-minutes\": '',\n              \"requests-time_between_check-seconds\": '',\n              },\n        follow_redirects=True\n    )\n\n\n    assert REQUIRE_ATLEAST_ONE_TIME_PART_MESSAGE_DEFAULT.encode('utf-8') in res.data\n    delete_all_watches(client)\n\n\ndef test_recheck_time_field_validation_single_watch(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Tests that the global settings time field has atleast one value for week/day/hours/minute/seconds etc entered\n    class globalSettingsRequestForm(Form):\n        time_between_check = RequiredFormField(TimeBetweenCheckForm)\n    \"\"\"\n    test_url = url_for('test_endpoint', _external=True)\n\n    # Add our URL to the import page\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            'fetch_backend': \"html_requests\",\n            \"time_between_check_use_default\": \"\",  # OFF\n            \"time_between_check-weeks\": '',\n            \"time_between_check-days\": '',\n            \"time_between_check-hours\": '',\n            \"time_between_check-minutes\": '',\n            \"time_between_check-seconds\": '',\n        },\n        follow_redirects=True\n    )\n\n\n    assert REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT.encode('utf-8') in res.data\n\n    # Now set some time\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            'fetch_backend': \"html_requests\",\n            \"time_between_check_use_default\": \"\",  # OFF\n            \"time_between_check-weeks\": '',\n            \"time_between_check-days\": '',\n            \"time_between_check-hours\": '',\n            \"time_between_check-minutes\": '5',\n            \"time_between_check-seconds\": '',\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    assert REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT.encode('utf-8') not in res.data\n\n    # Now set to use defaults\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n            \"url\": test_url,\n            'fetch_backend': \"html_requests\",\n            \"time_between_check_use_default\": \"y\",  # ON YES\n            \"time_between_check-weeks\": '',\n            \"time_between_check-days\": '',\n            \"time_between_check-hours\": '',\n            \"time_between_check-minutes\": '',\n            \"time_between_check-seconds\": '',\n        },\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    assert REQUIRE_ATLEAST_ONE_TIME_PART_WHEN_NOT_GLOBAL_DEFAULT.encode('utf-8') not in res.data\n    delete_all_watches(client)\n\ndef test_checkbox_open_diff_in_new_tab(client, live_server, measure_memory_usage, datastore_path):\n    \n    set_original_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # Make a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Test case 1 - checkbox is enabled in settings\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-ui-open_diff_in_new_tab\": \"1\"},\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n    # Force recheck\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n    \n    res = client.get(url_for(\"watchlist.index\"))\n    lines = res.data.decode().split(\"\\n\")\n\n    # Find link to diff page\n    target_line = None\n    for line in lines:\n        if '/diff' in line:\n            target_line = line.strip()\n            break\n\n    assert target_line != None\n    assert 'target=' in target_line\n\n    # Test case 2 - checkbox is disabled in settings\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-ui-open_diff_in_new_tab\": \"\"},\n        follow_redirects=True\n    )\n    assert b'Settings updated' in res.data\n\n    # Force recheck\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 1 watch for rechecking.' in res.data\n\n    wait_for_all_checks(client)\n    \n    res = client.get(url_for(\"watchlist.index\"))\n    lines = res.data.decode().split(\"\\n\")\n\n    # Find link to diff page\n    target_line = None\n    for line in lines:\n        if '/diff' in line:\n            target_line = line.strip()\n            break\n\n    assert target_line != None\n    assert 'target=' not in target_line\n\n    # Cleanup everything\n    delete_all_watches(client)\n\ndef test_page_title_listing_behaviour(client, live_server, measure_memory_usage, datastore_path):\n\n    set_original_response(extra_title=\"custom html\", datastore_path=datastore_path)\n\n    # either the manually entered title/description or the page link should be visible\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-ui-use_page_title_in_list\": \"\",\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)},\n        follow_redirects=True\n    )\n\n    assert b\"1 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    # We see the URL only, no title/description was manually entered\n    res = client.get(url_for(\"watchlist.index\"))\n    assert url_for('test_endpoint', _external=True).encode('utf-8') in res.data\n\n\n    # Now 'my title' should override\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n        \"url\": url_for('test_endpoint', _external=True),\n        \"title\": \"my title\",\n        \"fetch_backend\": \"html_requests\",\n        \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b\"my title\" in res.data\n\n    # Now we enable page <title> and unset the override title/description\n    res = client.post(\n        url_for(\"settings.settings_page\"),\n        data={\"application-ui-use_page_title_in_list\": \"y\",\n              \"requests-time_between_check-minutes\": 180,\n              'application-fetch_backend': \"html_requests\"},\n        follow_redirects=True\n    )\n    assert b\"Settings updated.\" in res.data\n\n    # Page title description override should take precedence\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b\"my title\" in res.data\n\n    # Remove page title description override and it should fall back to title\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\n        \"url\": url_for('test_endpoint', _external=True),\n        \"title\": \"\",\n        \"fetch_backend\": \"html_requests\",\n        \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # No page title description, and 'use_page_title_in_list' is on, it should show the <title>\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b\"head titlecustom html\" in res.data\n    delete_all_watches(client)\n\n\ndef test_ui_viewed_unread_flag(client, live_server, measure_memory_usage, datastore_path):\n\n    import time\n\n    set_original_response(datastore_path=datastore_path, extra_title=\"custom html\")\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"imports.import_page\"),\n        data={\"urls\": url_for('test_endpoint', _external=True)+\"\\r\\n\"+url_for('test_endpoint', _external=True)},\n        follow_redirects=True\n    )\n\n    assert b\"2 Imported\" in res.data\n    wait_for_all_checks(client)\n\n    set_modified_response(datastore_path=datastore_path)\n    res = client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    assert b'Queued 2 watches for rechecking.' in res.data\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'<span id=\"unread-tab-counter\">2</span>' in res.data\n    assert res.data.count(b'data-watch-uuid') == 2\n\n    # one should now be viewed, but two in total still\n    client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"))\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'<span id=\"unread-tab-counter\">1</span>' in res.data\n    assert res.data.count(b'data-watch-uuid') == 2\n\n    # check ?unread=1 works\n    res = client.get(url_for(\"watchlist.index\")+\"?unread=1\")\n    assert res.data.count(b'data-watch-uuid') == 1\n    assert b'<span id=\"unread-tab-counter\">1</span>' in res.data\n\n    # Mark all viewed test again\n    client.get(url_for(\"ui.mark_all_viewed\"), follow_redirects=True)\n    time.sleep(0.2)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'<span id=\"unread-tab-counter\">0</span>' in res.data\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_unique_lines.py",
    "content": "#!/usr/bin/env python3\n\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks, delete_all_watches\nimport os\n\n\ndef set_original_ignore_response(datastore_path):\n    test_return_data = \"\"\"<html>\n     <body>\n     <p>Some initial text</p>\n     <p>Which is across multiple lines</p>\n     <p>So let's see what happens.</p>\n     <p>&nbsp;  So let's see what happens.   <br> </p>\n     <p>A - sortable line</p> \n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\n# The same but just re-ordered the text\ndef set_modified_swapped_lines(datastore_path):\n    # Re-ordered and with some whitespacing, should get stripped() too.\n    test_return_data = \"\"\"<html>\n     <body>\n     <p>Some initial text</p>\n     <p>   So let's see what happens.</p>\n     <p>&nbsp;Which is across multiple lines</p>     \n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\ndef set_modified_swapped_lines_with_extra_text_for_sorting(datastore_path):\n    test_return_data = \"\"\"<html>\n     <body>\n     <p>&nbsp;Which is across multiple lines</p>     \n     <p>Some initial text</p>\n     <p>   So let's see what happens.</p>\n     <p>Z last</p>\n     <p>0 numerical</p>\n     <p>A uppercase</p>\n     <p>a lowercase</p>     \n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n\ndef set_modified_with_trigger_text_response(datastore_path):\n    test_return_data = \"\"\"<html>\n     <body>\n     <p>Some initial text</p>\n     <p>So let's see what happens.</p>\n     <p>and a new line!</p>\n     <p>Which is across multiple lines</p>     \n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\ndef test_unique_lines_functionality(client, live_server, measure_memory_usage, datastore_path):\n    \n\n\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"check_unique_lines\": \"y\",\n              \"url\": test_url,\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    assert b'has-unread-changes' not in res.data\n\n    #  Make a change\n    set_modified_swapped_lines(datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # It should report nothing found (no new 'has-unread-changes' class)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n\n    # Now set the content which contains the new text and re-ordered existing text\n    set_modified_with_trigger_text_response(datastore_path=datastore_path)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' in res.data\n    delete_all_watches(client)\n\ndef test_sort_lines_functionality(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    set_modified_swapped_lines_with_extra_text_for_sorting(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"sort_text_alphabetically\": \"n\",\n              \"url\": test_url,\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n\n    res = client.get(url_for(\"watchlist.index\"))\n    # Should be a change registered\n    assert b'has-unread-changes' in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert res.data.find(b'0 numerical') < res.data.find(b'Z last')\n    assert res.data.find(b'A uppercase') < res.data.find(b'Z last')\n    assert res.data.find(b'Some initial text') < res.data.find(b'Which is across multiple lines')\n    \n    delete_all_watches(client)\n\n\ndef test_extra_filters(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    set_original_ignore_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"remove_duplicate_lines\": \"y\",\n              \"trim_text_whitespace\": \"y\",\n              \"sort_text_alphabetically\": \"\",  # leave this OFF for testing\n              \"url\": test_url,\n              \"fetch_backend\": \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\")\n    )\n\n    assert res.data.count(b\"see what happens.\") == 1\n\n    # still should remain unsorted ('A - sortable line') stays at the end\n    assert res.data.find(b'A - sortable line') > res.data.find(b'Which is across multiple lines')\n\n    delete_all_watches(client)"
  },
  {
    "path": "changedetectionio/tests/test_watch_edited_flag.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest the watch edited flag functionality.\n\nThis tests the private __watch_was_edited flag that tracks when writable\nwatch fields are modified, which prevents skipping reprocessing when the\nwatch configuration has changed.\n\"\"\"\n\nimport os\nimport time\nfrom flask import url_for\nfrom .util import live_server_setup, wait_for_all_checks\n\n\ndef set_test_content(datastore_path):\n    \"\"\"Write test HTML content to endpoint-content.txt for test server.\"\"\"\n    test_html = \"\"\"<html>\n     <body>\n     <p>Test content for watch edited flag tests</p>\n     <p>This content stays the same across checks</p>\n     </body>\n     </html>\n    \"\"\"\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_html)\n\n\ndef test_watch_edited_flag_lifecycle(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test the full lifecycle of the was_edited flag:\n    1. Flag starts False when watch is created\n    2. Flag becomes True when writable fields are modified\n    3. Flag is reset False after worker processing\n    4. Flag stays False when readonly fields are modified\n    \"\"\"\n\n    # Setup - Add a watch\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": \"\", \"edit_and_watch_submit_button\": \"Edit > Watch\"},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data or b\"Updated watch\" in res.data\n\n    # Get the watch UUID\n    datastore = client.application.config.get('DATASTORE')\n    uuid = list(datastore.data['watching'].keys())[0]\n    watch = datastore.data['watching'][uuid]\n\n    # Reset flag after initial form submission (form sets fields which trigger the flag)\n    watch.reset_watch_edited_flag()\n\n    # Test 1: Flag should be False after reset\n    assert not watch.was_edited, \"Flag should be False after reset\"\n\n    # Test 2: Modify a writable field (title) - flag should become True\n    watch['title'] = 'New Title'\n    assert watch.was_edited, \"Flag should be True after modifying writable field 'title'\"\n\n    # Test 3: Reset flag manually (simulating what worker does)\n    watch.reset_watch_edited_flag()\n    assert not watch.was_edited, \"Flag should be False after reset\"\n\n    # Test 4: Modify another writable field (url) - flag should become True again\n    watch['url'] = 'https://example.com'\n    assert watch.was_edited, \"Flag should be True after modifying writable field 'url'\"\n\n    # Test 5: Reset and modify a readonly field - flag should stay False\n    watch.reset_watch_edited_flag()\n    assert not watch.was_edited, \"Flag should be False after reset\"\n\n    # Modify readonly field (uuid) - should not set flag\n    old_uuid = watch['uuid']\n    watch['uuid'] = 'readonly-test-uuid'\n    assert not watch.was_edited, \"Flag should stay False when modifying readonly field 'uuid'\"\n    watch['uuid'] = old_uuid  # Restore original\n\n    # Note: Worker reset behavior is tested in test_check_removed_line_contains_trigger\n    # and test_watch_edited_flag_prevents_skip\n\n    print(\"✓ All watch edited flag lifecycle tests passed\")\n\n\ndef test_watch_edited_flag_dict_methods(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that the flag is set correctly by various dict methods:\n    - __setitem__ (watch['key'] = value)\n    - update() (watch.update({'key': value}))\n    - setdefault() (watch.setdefault('key', default))\n    - pop() (watch.pop('key'))\n    - __delitem__ (del watch['key'])\n    \"\"\"\n\n    # Setup - Add a watch\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": \"\", \"edit_and_watch_submit_button\": \"Edit > Watch\"},\n        follow_redirects=True\n    )\n\n    datastore = client.application.config.get('DATASTORE')\n    uuid = list(datastore.data['watching'].keys())[0]\n    watch = datastore.data['watching'][uuid]\n\n    # Test __setitem__\n    watch.reset_watch_edited_flag()\n    watch['title'] = 'Test via setitem'\n    assert watch.was_edited, \"Flag should be True after __setitem__ on writable field\"\n\n    # Test update() with dict\n    watch.reset_watch_edited_flag()\n    watch.update({'title': 'Test via update dict'})\n    assert watch.was_edited, \"Flag should be True after update() with writable field\"\n\n    # Test update() with kwargs\n    watch.reset_watch_edited_flag()\n    watch.update(title='Test via update kwargs')\n    assert watch.was_edited, \"Flag should be True after update() kwargs with writable field\"\n\n    # Test setdefault() on new key\n    watch.reset_watch_edited_flag()\n    watch.setdefault('title', 'Should not be set')  # Key exists, no change\n    assert not watch.was_edited, \"Flag should stay False when setdefault() doesn't change existing key\"\n\n    watch.setdefault('custom_field', 'New value')  # New key\n    assert watch.was_edited, \"Flag should be True after setdefault() creates new writable field\"\n\n    # Test pop() on writable field\n    watch.reset_watch_edited_flag()\n    watch.pop('custom_field', None)\n    assert watch.was_edited, \"Flag should be True after pop() on writable field\"\n\n    # Test __delitem__ on writable field\n    watch.reset_watch_edited_flag()\n    watch['temp_field'] = 'temp'\n    watch.reset_watch_edited_flag()  # Reset after adding\n    del watch['temp_field']\n    assert watch.was_edited, \"Flag should be True after __delitem__ on writable field\"\n\n    print(\"✓ All dict methods correctly set the flag\")\n\n\ndef test_watch_edited_flag_prevents_skip(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that the was_edited flag prevents skipping reprocessing.\n    When watch configuration is edited, it should reprocess even if content unchanged.\n    After worker processing, flag should be reset and subsequent checks can skip.\n    \"\"\"\n\n    # Setup test content\n    set_test_content(datastore_path)\n\n    # Setup - Add a watch\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": \"\", \"edit_and_watch_submit_button\": \"Edit > Watch\"},\n        follow_redirects=True\n    )\n    assert b\"Watch added\" in res.data or b\"Updated watch\" in res.data\n\n    datastore = client.application.config.get('DATASTORE')\n    uuid = list(datastore.data['watching'].keys())[0]\n    watch = datastore.data['watching'][uuid]\n\n    # Unpause the watch (watches are paused by default in tests)\n    watch['paused'] = False\n\n    # Run first check to establish baseline\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Verify first check completed successfully - checksum file should exist\n    checksum_file = os.path.join(datastore_path, uuid, 'last-checksum.txt')\n    assert os.path.isfile(checksum_file), \"First check should create last-checksum.txt file\"\n\n    # Reset the was_edited flag (simulating clean state after processing)\n    watch.reset_watch_edited_flag()\n    assert not watch.was_edited, \"Flag should be False after reset\"\n\n    # Run second check without any changes - should skip via checksumFromPreviousCheckWasTheSame\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # Verify it was skipped (last_check_status should indicate skip)\n    # Note: The actual skip is tested in test_check_removed_line_contains_trigger\n    # Here we're focused on the was_edited flag interaction\n\n    # Now modify the watch - flag should become True\n    watch['title'] = 'Modified Title'\n    assert watch.was_edited, \"Flag should be True after modifying watch\"\n\n    # Run third check - should NOT skip because was_edited=True even though content unchanged\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    # After worker processing, the flag should be reset by the worker\n    # This reset happens in the processor's run() method after processing completes\n    assert not watch.was_edited, \"Flag should be False after worker processing\"\n\n    print(\"✓ was_edited flag correctly prevents skip and is reset by worker\")\n\n\ndef test_watch_edited_flag_system_fields(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"\n    Test that system fields (readonly + additional system fields) don't trigger the flag.\n    \"\"\"\n\n    # Setup - Add a watch\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": \"\", \"edit_and_watch_submit_button\": \"Edit > Watch\"},\n        follow_redirects=True\n    )\n\n    datastore = client.application.config.get('DATASTORE')\n    uuid = list(datastore.data['watching'].keys())[0]\n    watch = datastore.data['watching'][uuid]\n\n    # Test readonly fields from OpenAPI spec\n    readonly_fields = ['uuid', 'date_created', 'last_viewed']\n    for field in readonly_fields:\n        watch.reset_watch_edited_flag()\n        if field in watch:\n            old_value = watch[field]\n            watch[field] = 'modified-readonly-value'\n            assert not watch.was_edited, f\"Flag should stay False when modifying readonly field '{field}'\"\n            watch[field] = old_value  # Restore\n\n    # Test additional system fields not in OpenAPI spec yet\n    system_fields = ['last_check_status']\n    for field in system_fields:\n        watch.reset_watch_edited_flag()\n        watch[field] = 'system-value'\n        assert not watch.was_edited, f\"Flag should stay False when modifying system field '{field}'\"\n\n    # Test that content-type (readonly per OpenAPI) doesn't trigger flag\n    watch.reset_watch_edited_flag()\n    watch['content-type'] = 'text/html'\n    assert not watch.was_edited, \"Flag should stay False when modifying 'content-type' (readonly)\"\n\n    print(\"✓ System fields correctly don't trigger the flag\")\n"
  },
  {
    "path": "changedetectionio/tests/test_watch_fields_storage.py",
    "content": "import time\nfrom flask import url_for\nfrom urllib.request import urlopen\nfrom . util import set_original_response, set_modified_response, live_server_setup\n\n\ndef test_check_watch_field_storage(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n    test_url = \"http://somerandomsitewewatch.com\"\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={ \"notification_urls\": \"json://127.0.0.1:30000\\r\\njson://128.0.0.1\\r\\n\",\n               \"time_between_check-minutes\": 126,\n               \"include_filters\" : \".fooclass\",\n               \"title\" : \"My title\",\n               \"ignore_text\" : \"ignore this\",\n               \"url\": test_url,\n               \"tags\": \"woohoo\",\n               \"headers\": \"curl:foo\",\n               'fetch_backend': \"html_requests\",\n               \"time_between_check_use_default\": \"y\"\n               },\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    # checks that we dont get an error when using blank lines in the field value\n    assert not b\"json://127.0.0.1\\n\\njson\" in res.data\n    assert not b\"json://127.0.0.1\\r\\n\\njson\" in res.data\n    assert not b\"json://127.0.0.1\\r\\n\\rjson\" in res.data\n\n    assert b\"json://127.0.0.1\" in res.data\n    assert b\"json://128.0.0.1\" in res.data\n\n    assert b\"126\" in res.data\n    assert b\".fooclass\" in res.data\n    assert b\"My title\" in res.data\n    assert b\"ignore this\" in res.data\n    assert b\"http://somerandomsitewewatch.com\" in res.data\n    assert b\"woohoo\" in res.data\n    assert b\"curl: foo\" in res.data\n\n"
  },
  {
    "path": "changedetectionio/tests/test_xpath_default_namespace.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nUnit tests for XPath default namespace handling in RSS/Atom feeds.\nTests the fix for issue where //title/text() returns empty on feeds with default namespaces.\n\nReal-world test data from https://github.com/microsoft/PowerToys/releases.atom\n\"\"\"\n\nimport sys\nimport os\nimport pytest\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nimport html_tools\n\n\n# Real-world Atom feed with default namespace from GitHub PowerToys releases\n# This is the actual format that was failing before the fix\natom_feed_with_default_ns = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\" xml:lang=\"en-US\">\n  <id>tag:github.com,2008:https://github.com/microsoft/PowerToys/releases</id>\n  <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/microsoft/PowerToys/releases\"/>\n  <link type=\"application/atom+xml\" rel=\"self\" href=\"https://github.com/microsoft/PowerToys/releases.atom\"/>\n  <title>Release notes from PowerToys</title>\n  <updated>2025-10-23T08:53:12Z</updated>\n  <entry>\n    <id>tag:github.com,2008:Repository/184456251/v0.95.1</id>\n    <updated>2025-10-24T14:20:14Z</updated>\n    <link rel=\"alternate\" type=\"text/html\" href=\"https://github.com/microsoft/PowerToys/releases/tag/v0.95.1\"/>\n    <title>Release 0.95.1</title>\n    <content type=\"html\">&lt;p&gt;This patch release fixes several important stability issues.&lt;/p&gt;</content>\n    <author>\n      <name>Jaylyn-Barbee</name>\n    </author>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Repository/184456251/v0.95.0</id>\n    <updated>2025-10-17T12:51:21Z</updated>\n    <link rel=\"alternate\" type=\"text/html\" href=\"https://github.com/microsoft/PowerToys/releases/tag/v0.95.0\"/>\n    <title>Release v0.95.0</title>\n    <content type=\"html\">&lt;p&gt;New features, stability, optimization improvements.&lt;/p&gt;</content>\n    <author>\n      <name>Jaylyn-Barbee</name>\n    </author>\n  </entry>\n</feed>\"\"\"\n\n# RSS feed without default namespace\nrss_feed_no_default_ns = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\">\n  <channel>\n    <title>Channel Title</title>\n    <description>Channel Description</description>\n    <item>\n      <title>Item 1 Title</title>\n      <description>Item 1 Description</description>\n    </item>\n    <item>\n      <title>Item 2 Title</title>\n      <description>Item 2 Description</description>\n    </item>\n  </channel>\n</rss>\"\"\"\n\n# RSS 2.0 feed with namespace prefix (not default)\nrss_feed_with_ns_prefix = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n     xmlns:content=\"http://purl.org/rss/1.0/modules/content/\"\n     xmlns:atom=\"http://www.w3.org/2005/Atom\"\n     version=\"2.0\">\n  <channel>\n    <title>Channel Title</title>\n    <atom:link href=\"http://example.com/feed\" rel=\"self\" type=\"application/rss+xml\"/>\n    <item>\n      <title>Item Title</title>\n      <dc:creator>Author Name</dc:creator>\n    </item>\n  </channel>\n</rss>\"\"\"\n\n\nclass TestXPathDefaultNamespace:\n    \"\"\"Test XPath queries on feeds with and without default namespaces.\"\"\"\n\n    def test_atom_feed_simple_xpath_with_xpath_filter(self):\n        \"\"\"Test that //title/text() works on Atom feed with default namespace using xpath_filter.\"\"\"\n        result = html_tools.xpath_filter('//title/text()', atom_feed_with_default_ns, is_xml=True)\n        assert 'Release notes from PowerToys' in result\n        assert 'Release 0.95.1' in result\n        assert 'Release v0.95.0' in result\n\n    def test_atom_feed_nested_xpath_with_xpath_filter(self):\n        \"\"\"Test nested XPath like //entry/title/text() on Atom feed.\"\"\"\n        result = html_tools.xpath_filter('//entry/title/text()', atom_feed_with_default_ns, is_xml=True)\n        assert 'Release 0.95.1' in result\n        assert 'Release v0.95.0' in result\n        # Should NOT include the feed title\n        assert 'Release notes from PowerToys' not in result\n\n    def test_atom_feed_other_elements_with_xpath_filter(self):\n        \"\"\"Test that other elements like //updated/text() work on Atom feed.\"\"\"\n        result = html_tools.xpath_filter('//updated/text()', atom_feed_with_default_ns, is_xml=True)\n        assert '2025-10-23T08:53:12Z' in result\n        assert '2025-10-24T14:20:14Z' in result\n\n    def test_rss_feed_without_namespace(self):\n        \"\"\"Test that //title/text() works on RSS feed without default namespace.\"\"\"\n        result = html_tools.xpath_filter('//title/text()', rss_feed_no_default_ns, is_xml=True)\n        assert 'Channel Title' in result\n        assert 'Item 1 Title' in result\n        assert 'Item 2 Title' in result\n\n    def test_rss_feed_nested_xpath(self):\n        \"\"\"Test nested XPath on RSS feed without default namespace.\"\"\"\n        result = html_tools.xpath_filter('//item/title/text()', rss_feed_no_default_ns, is_xml=True)\n        assert 'Item 1 Title' in result\n        assert 'Item 2 Title' in result\n        # Should NOT include channel title\n        assert 'Channel Title' not in result\n\n    def test_rss_feed_with_prefixed_namespaces(self):\n        \"\"\"Test that feeds with namespace prefixes (not default) still work.\"\"\"\n        result = html_tools.xpath_filter('//title/text()', rss_feed_with_ns_prefix, is_xml=True)\n        assert 'Channel Title' in result\n        assert 'Item Title' in result\n\n    def test_local_name_workaround_still_works(self):\n        \"\"\"Test that local-name() workaround still works for Atom feeds.\"\"\"\n        result = html_tools.xpath_filter('//*[local-name()=\"title\"]/text()', atom_feed_with_default_ns, is_xml=True)\n        assert 'Release notes from PowerToys' in result\n        assert 'Release 0.95.1' in result\n\n    def test_xpath1_filter_without_default_namespace(self):\n        \"\"\"Test xpath1_filter works on RSS without default namespace.\"\"\"\n        result = html_tools.xpath1_filter('//title/text()', rss_feed_no_default_ns, is_xml=True)\n        assert 'Channel Title' in result\n        assert 'Item 1 Title' in result\n\n    def test_xpath1_filter_with_default_namespace_returns_empty(self):\n        \"\"\"Test that xpath1_filter returns empty on Atom with default namespace (known limitation).\"\"\"\n        result = html_tools.xpath1_filter('//title/text()', atom_feed_with_default_ns, is_xml=True)\n        # xpath1_filter (lxml) doesn't support default namespaces, so this returns empty\n        assert result == ''\n\n    def test_xpath1_filter_local_name_workaround(self):\n        \"\"\"Test that xpath1_filter works with local-name() workaround on Atom feeds.\"\"\"\n        result = html_tools.xpath1_filter('//*[local-name()=\"title\"]/text()', atom_feed_with_default_ns, is_xml=True)\n        assert 'Release notes from PowerToys' in result\n        assert 'Release 0.95.1' in result\n\n\nif __name__ == '__main__':\n    pytest.main([__file__, '-v'])\n"
  },
  {
    "path": "changedetectionio/tests/test_xpath_selector.py",
    "content": "# -*- coding: utf-8 -*-\n\n\nfrom flask import url_for\nfrom .util import  wait_for_all_checks, delete_all_watches\nfrom ..processors.magic import RSS_XML_CONTENT_TYPES\nimport os\n\n\ndef set_rss_atom_feed_response(datastore_path, header='', ):\n    test_return_data = f\"\"\"{header}<!-- Generated on Wed, 08 Oct 2025 08:42:33 -0700, really really honestly  -->\n<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n<channel>\n    <atom:link href=\"https://store.waterpowered.com/news/collection//\" rel=\"self\" type=\"application/rss+xml\"/>\n    <title>RSS Feed</title>\n    <link>\n        <![CDATA[ https://store.waterpowered.com/news/collection// ]]>\n    </link>\n    <description>\n        <![CDATA[ Events and Announcements for ]]>\n    </description>\n    <language>en-us</language>\n    <generator>water News RSS</generator>\n    <item>\n        <title> 🍁 Lets go discount</title>\n        <description><p class=\"bb_paragraph\">ok heres the description</p></description>\n        <link>\n        <![CDATA[ https://store.waterpowered.com/news/app/1643320/view/511845698831908921 ]]>\n        </link>\n        <pubDate>Wed, 08 Oct 2025 15:28:55 +0000</pubDate>\n        <guid isPermaLink=\"true\">https://store.waterpowered.com/news/app/1643320/view/511845698831908921</guid>\n        <enclosure url=\"https://clan.fastly.waterstatic.com/images/40721482/42822e5f00b2becf520ace9500981bb56f3a89f2.jpg\" length=\"0\" type=\"image/jpeg\"/>\n    </item>\n</channel>\n</rss>\"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    return None\n\n\n\ndef set_original_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div class=\"sametext\">Some text thats the same</div>\n     <div class=\"changetext\">Some text that will change</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n    return None\n\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  THIS CHANGES AND SHOULDNT TRIGGER A CHANGE<br>\n     <div class=\"sametext\">Some text thats the same</div>\n     <div class=\"changetext\">Some new text</div>\n     </body>\n     </html>\n    \"\"\"\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(test_return_data)\n\n    return None\n\n\n# Handle utf-8 charset replies https://github.com/dgtlmoon/changedetection.io/pull/613\ndef test_check_xpath_filter_utf8(client, live_server, measure_memory_usage, datastore_path):\n    filter = '//item/*[self::description]'\n\n    d = '''<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss xmlns:taxo=\"http://purl.org/rss/1.0/modules/taxonomy/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" version=\"2.0\">\n\t<channel>\n\t\t<title>rpilocator.com</title>\n\t\t<link>https://rpilocator.com</link>\n\t\t<description>Find Raspberry Pi Computers in Stock</description>\n\t\t<lastBuildDate>Thu, 19 May 2022 23:27:30 GMT</lastBuildDate>\n\t\t<image>\n\t\t\t<url>https://rpilocator.com/favicon.png</url>\n\t\t\t<title>rpilocator.com</title>\n\t\t\t<link>https://rpilocator.com/</link>\n\t\t\t<width>32</width>\n\t\t\t<height>32</height>\n\t\t</image>\n\t\t<item>\n\t\t\t<title>Stock Alert (UK): RPi CM4 - 1GB RAM, No MMC, No Wifi is In Stock at Pimoroni</title>\n\t\t\t<description>Stock Alert (UK): RPi CM4 - 1GB RAM, No MMC, No Wifi is In Stock at Pimoroni</description>\n\t\t\t<link>https://rpilocator.com?vendor=pimoroni&amp;utm_source=feed&amp;utm_medium=rss</link>\n\t\t\t<category>pimoroni</category>\n\t\t\t<category>UK</category>\n\t\t\t<category>CM4</category>\n\t\t\t<guid isPermaLink=\"false\">F9FAB0D9-DF6F-40C8-8DEE5FC0646BB722</guid>\n\t\t\t<pubDate>Thu, 19 May 2022 14:32:32 GMT</pubDate>\n\t\t</item>\n\t</channel>\n</rss>'''\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(d)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True, content_type=\"application/rss+xml;charset=UTF-8\")\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": filter, \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'Unicode strings with encoding declaration are not supported.' not in res.data\n    delete_all_watches(client)\n\n\n# Handle utf-8 charset replies https://github.com/dgtlmoon/changedetection.io/pull/613\ndef test_check_xpath_text_function_utf8(client, live_server, measure_memory_usage, datastore_path):\n    filter = '//item/title/text()'\n\n    d = '''<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss xmlns:taxo=\"http://purl.org/rss/1.0/modules/taxonomy/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" version=\"2.0\">\n\t<channel>\n\t\t<title>rpilocator.com</title>\n\t\t<link>https://rpilocator.com</link>\n\t\t<description>Find Raspberry Pi Computers in Stock</description>\n\t\t<lastBuildDate>Thu, 19 May 2022 23:27:30 GMT</lastBuildDate>\n\t\t<image>\n\t\t\t<url>https://rpilocator.com/favicon.png</url>\n\t\t\t<title>rpilocator.com</title>\n\t\t\t<link>https://rpilocator.com/</link>\n\t\t\t<width>32</width>\n\t\t\t<height>32</height>\n\t\t</image>\n\t\t<item>\n\t\t\t<title>Stock Alert (UK): RPi CM4</title>\n\t\t\t<foo>something else unrelated</foo>\n\t\t</item>\n\t\t<item>\n\t\t\t<title>Stock Alert (UK): Big monitor</title>\n\t\t\t<foo>something else unrelated</foo>\n\t\t</item>\t\t\n\t</channel>\n</rss>'''\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(d)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True, content_type=\"application/rss+xml;charset=UTF-8\")\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": filter, \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'Unicode strings with encoding declaration are not supported.' not in res.data\n\n    # The service should echo back the request headers\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b'Stock Alert (UK): RPi CM4' in res.data\n    assert b'Stock Alert (UK): Big monitor' in res.data\n\n    delete_all_watches(client)\n\n\ndef test_check_markup_xpath_filter_restriction(client, live_server, measure_memory_usage, datastore_path):\n    xpath_filter = \"//*[contains(@class, 'sametext')]\"\n\n    set_original_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # Goto the edit page, add our ignore text\n    # Add our URL to the import page\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": xpath_filter, \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"Updated watch.\" in res.data\n\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    # view it/reset state back to viewed\n    client.get(url_for(\"ui.ui_diff.diff_history_page\", uuid=\"first\"), follow_redirects=True)\n\n    #  Make a change\n    set_modified_response(datastore_path=datastore_path)\n\n    # Trigger a check\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    # Give the thread time to pick it up\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'has-unread-changes' not in res.data\n    delete_all_watches(client)\n\n\ndef test_xpath_validation(client, live_server, measure_memory_usage, datastore_path):\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": \"/something horrible\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"is not a valid XPath expression\" in res.data\n    delete_all_watches(client)\n\n\ndef test_xpath23_prefix_validation(client, live_server, measure_memory_usage, datastore_path):\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": \"xpath:/something horrible\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"is not a valid XPath expression\" in res.data\n    delete_all_watches(client)\n\ndef test_xpath1_lxml(client, live_server, measure_memory_usage, datastore_path):\n    \n\n    d = '''<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n    <rss xmlns:taxo=\"http://purl.org/rss/1.0/modules/taxonomy/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" version=\"2.0\">\n    \t<channel>\n    \t\t<title>rpilocator.com</title>\n    \t\t<link>https://rpilocator.com</link>\n    \t\t<description>Find Raspberry Pi Computers in Stock</description>\n    \t\t<lastBuildDate>Thu, 19 May 2022 23:27:30 GMT</lastBuildDate>\n    \t\t<image>\n    \t\t\t<url>https://rpilocator.com/favicon.png</url>\n    \t\t\t<title>rpilocator.com</title>\n    \t\t\t<link>https://rpilocator.com/</link>\n    \t\t\t<width>32</width>\n    \t\t\t<height>32</height>\n    \t\t</image>\n    \t\t<item>\n    \t\t\t<title>Stock Alert (UK): RPi CM4</title>\n    \t\t\t<foo>something else unrelated</foo>\n    \t\t</item>\n    \t\t<item>\n    \t\t\t<title>Stock Alert (UK): Big monitorěěěě</title>\n    \t\t\t<foo>something else unrelated</foo>\n    \t\t</item>\t\t\n    \t</channel>\n    </rss>'''.encode('utf-8')\n\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"wb\") as f:\n        f.write(d)\n\n\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": \"xpath1://title/text()\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\",\n              'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    ##### #2312\n    wait_for_all_checks(client)\n    res = client.get(url_for(\"watchlist.index\"))\n    assert b'_ElementStringResult' not in res.data # tested with 5.1.1 when it was removed and 5.1.0\n    assert b'Exception' not in res.data\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"rpilocator.com\" in res.data  # in selector\n    assert \"Stock Alert (UK): Big monitorěěěě\".encode('utf-8') in res.data  # not in selector\n\n    #####\n\n\ndef test_xpath1_validation(client, live_server, measure_memory_usage, datastore_path):\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": \"xpath1:/something horrible\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\", 'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n    assert b\"is not a valid XPath expression\" in res.data\n    delete_all_watches(client)\n\n\n# actually only really used by the distll.io importer, but could be handy too\ndef test_check_with_prefix_include_filters(client, live_server, measure_memory_usage, datastore_path):\n    delete_all_watches(client)\n\n    set_original_response(datastore_path=datastore_path)\n    wait_for_all_checks(client)\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": \"xpath://*[contains(@class, 'sametext')]\", \"url\": test_url, \"tags\": \"\", \"headers\": \"\",\n              'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"Some text thats the same\" in res.data  # in selector\n    assert b\"Some text that will change\" not in res.data  # not in selector\n\n    delete_all_watches(client)\n\n\ndef test_various_rules(client, live_server, measure_memory_usage, datastore_path):\n    # Just check these don't error\n    ##  live_server_setup(live_server) # Setup on conftest per function\n    with open(os.path.join(datastore_path, \"endpoint-content.txt\"), \"w\") as f:\n        f.write(\"\"\"<html>\n       <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <div class=\"sametext\">Some text thats the same</div>\n     <div class=\"changetext\">Some text that will change</div>\n     <a href=''>some linky </a>\n     <a href=''>another some linky </a>\n     <!-- related to https://github.com/dgtlmoon/changedetection.io/pull/1774 -->\n     <input   type=\"email\"   id=\"email\" />     \n     </body>\n     </html>\n    \"\"\")\n\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    for r in ['//div', '//a', 'xpath://div', 'xpath://a']:\n        res = client.post(\n            url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n            data={\"include_filters\": r,\n                  \"url\": test_url,\n                  \"tags\": \"\",\n                  \"headers\": \"\",\n                  'fetch_backend': \"html_requests\",\n                  \"time_between_check_use_default\": \"y\"},\n            follow_redirects=True\n        )\n        wait_for_all_checks(client)\n        assert b\"Updated watch.\" in res.data\n        res = client.get(url_for(\"watchlist.index\"))\n        assert b'fetch-error' not in res.data, f\"Should not see errors after '{r} filter\"\n\n    delete_all_watches(client)\n\n\ndef test_xpath_20(client, live_server, measure_memory_usage, datastore_path):\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    set_original_response(datastore_path=datastore_path)\n\n    test_url = url_for('test_endpoint', _external=True)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\"include_filters\": \"//*[contains(@class, 'sametext')]|//*[contains(@class, 'changetext')]\",\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuid),\n        follow_redirects=True\n    )\n\n    assert b\"Some text thats the same\" in res.data  # in selector\n    assert b\"Some text that will change\" in res.data  # in selector\n\n    delete_all_watches(client)\n\n\ndef test_xpath_20_function_count(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": \"xpath:count(//div) * 123456789987654321\",\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"246913579975308642\" in res.data  # in selector\n\n    delete_all_watches(client)\n\n\ndef test_xpath_20_function_count2(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        data={\"include_filters\": \"/html/body/count(div) * 123456789987654321\",\n              \"url\": test_url,\n              \"tags\": \"\",\n              \"headers\": \"\",\n              'fetch_backend': \"html_requests\",\n              \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"246913579975308642\" in res.data  # in selector\n\n    delete_all_watches(client)\n\n\ndef test_xpath_20_function_string_join_matches(client, live_server, measure_memory_usage, datastore_path):\n    set_original_response(datastore_path=datastore_path)\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', _external=True)\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\n            \"include_filters\": \"xpath:string-join(//*[contains(@class, 'sametext')]|//*[matches(@class, 'changetext')], 'specialconjunction')\",\n            \"url\": test_url,\n            \"tags\": \"\",\n            \"headers\": \"\",\n            'fetch_backend': \"html_requests\",\n            \"time_between_check_use_default\": \"y\"},\n        follow_redirects=True\n    )\n\n    assert b\"Updated watch.\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuid),\n        follow_redirects=True\n    )\n\n    assert b\"Some text thats the samespecialconjunctionSome text that will change\" in res.data  # in selector\n\n    delete_all_watches(client)\n\n\ndef _subtest_xpath_rss(client, datastore_path, content_type='text/html'):\n\n    # Add our URL to the import page\n    test_url = url_for('test_endpoint', content_type=content_type, _external=True)\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n        data={\n            \"url\": test_url,\n            \"include_filters\": \"xpath://item\",\n            \"tags\": '',\n            \"fetch_backend\": \"html_requests\",\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n\n    assert b\"unpaused\" in res.data\n    wait_for_all_checks(client)\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n\n    assert b\"Lets go discount\" in res.data, f\"When testing for Lets go discount called with content type '{content_type}'\"\n    assert b\"Events and Announcements\" not in res.data, f\"When testing for Lets go discount called with content type '{content_type}'\" # It should not be here because thats not our selector target\n\n    delete_all_watches(client)\n\n# Be sure all-in-the-wild types of RSS feeds work with xpath\ndef test_rss_xpath(client, live_server, measure_memory_usage, datastore_path):\n    for feed_header in ['', '<?xml version=\"1.0\" encoding=\"utf-8\"?>']:\n        set_rss_atom_feed_response(header=feed_header, datastore_path=datastore_path)\n        for content_type in RSS_XML_CONTENT_TYPES:\n            _subtest_xpath_rss(client, content_type=content_type, datastore_path=datastore_path)\n\n\n# GHSA-6fmw-82m7-jq6p — XPath arbitrary file read via unparsed-text() and friends\n# Unit-level: verify xpath_filter() and SafeXPath3Parser block all dangerous functions.\ndef test_xpath_blocked_functions_unit():\n    \"\"\"Dangerous XPath 3.0 functions must be rejected at the parser level (no live server needed).\"\"\"\n    import elementpath\n    from changedetectionio.html_tools import xpath_filter, SafeXPath3Parser\n    from lxml import html\n\n    html_content = '<html><body><p>safe content</p></body></html>'\n\n    dangerous_expressions = [\n        \"unparsed-text('file:///etc/passwd')\",\n        \"unparsed-text-lines('file:///etc/passwd')\",\n        \"unparsed-text-available('file:///etc/passwd')\",\n        \"doc('file:///etc/passwd')\",\n        \"doc-available('file:///etc/passwd')\",\n        \"environment-variable('PATH')\",\n        \"available-environment-variables()\",\n    ]\n\n    for expr in dangerous_expressions:\n        # xpath_filter() must raise, not silently return file contents\n        try:\n            result = xpath_filter(expr, html_content)\n            assert False, f\"xpath_filter should have raised for: {expr!r}, got: {result!r}\"\n        except elementpath.ElementPathError:\n            pass  # expected\n\n        # SafeXPath3Parser must reject the expression at parse time\n        tree = html.fromstring(html_content)\n        try:\n            elementpath.select(tree, expr, parser=SafeXPath3Parser)\n            assert False, f\"SafeXPath3Parser should have raised for: {expr!r}\"\n        except elementpath.ElementPathError:\n            pass  # expected\n\n    # Sanity check: normal XPath still works\n    result = xpath_filter('//p/text()', html_content)\n    assert result == 'safe content'\n\n\n# GHSA-6fmw-82m7-jq6p — form validation must also reject dangerous XPath expressions.\ndef test_xpath_blocked_functions_form_validation(client, live_server, measure_memory_usage, datastore_path):\n    \"\"\"Edit-form validation must reject dangerous XPath 3.0 functions before they are stored.\"\"\"\n    from flask import url_for\n\n    set_original_response(datastore_path=datastore_path)\n    test_url = url_for('test_endpoint', _external=True)\n    client.application.config.get('DATASTORE').add_watch(url=test_url)\n    client.get(url_for(\"ui.form_watch_checknow\"), follow_redirects=True)\n    wait_for_all_checks(client)\n\n    dangerous_expressions = [\n        \"xpath:unparsed-text('file:///etc/passwd')\",\n        \"xpath:environment-variable('PATH')\",\n        \"xpath:doc('file:///etc/passwd')\",\n    ]\n\n    for expr in dangerous_expressions:\n        res = client.post(\n            url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n            data={\"include_filters\": expr, \"url\": test_url, \"tags\": \"\", \"headers\": \"\",\n                  'fetch_backend': \"html_requests\", \"time_between_check_use_default\": \"y\"},\n            follow_redirects=True\n        )\n        assert b\"is not a valid XPath expression\" in res.data, \\\n            f\"Form should reject dangerous expression: {expr!r}\"\n\n    delete_all_watches(client)\n"
  },
  {
    "path": "changedetectionio/tests/test_xpath_selector_unit.py",
    "content": "import sys\nimport os\nimport pytest\n\nfrom changedetectionio import html_tools\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n\n# test generation guide.\n# 1. Do not include encoding in the xml declaration if the test object is a str type.\n# 2. Always paraphrase test.\n\nhotels = \"\"\"\n<hotel>\n  <branch location=\"California\">\n    <staff>\n      <given_name>Christopher</given_name>\n      <surname>Anderson</surname>\n      <age>25</age>\n    </staff>\n    <staff>\n      <given_name>Christopher</given_name>\n      <surname>Carter</surname>\n      <age>30</age>\n    </staff>\n  </branch>\n  <branch location=\"Las Vegas\">\n    <staff>\n      <given_name>Lisa</given_name>\n      <surname>Walker</surname>\n      <age>60</age>\n    </staff>\n    <staff>\n      <given_name>Jessica</given_name>\n      <surname>Walker</surname>\n      <age>32</age>\n    </staff>\n    <staff>\n      <given_name>Jennifer</given_name>\n      <surname>Roberts</surname>\n      <age>50</age>\n    </staff>\n  </branch>\n</hotel>\"\"\"\n\n@pytest.mark.parametrize(\"html_content\", [hotels])\n@pytest.mark.parametrize(\"xpath, answer\", [('(//staff/given_name, //staff/age)', '25'),\n                          (\"xs:date('2023-10-10')\", '2023-10-10'),\n                          (\"if (/hotel/branch[@location = 'California']/staff[1]/age = 25) then 'is 25' else 'is not 25'\", 'is 25'),\n                          (\"if (//hotel/branch[@location = 'California']/staff[1]/age = 25) then 'is 25' else 'is not 25'\", 'is 25'),\n                          (\"if (count(/hotel/branch/staff) = 5) then true() else false()\", 'true'),\n                          (\"if (count(//hotel/branch/staff) = 5) then true() else false()\", 'true'),\n                          (\"for $i in /hotel/branch/staff return if ($i/age >= 40) then upper-case($i/surname) else lower-case($i/surname)\", 'anderson'),\n                          (\"given_name  =  'Christopher' and age  =  40\", 'false'),\n                          (\"//given_name  =  'Christopher' and //age  =  40\", 'false'),\n                          #(\"(staff/given_name, staff/age)\", 'Lisa'),\n                          (\"(//staff/given_name, //staff/age)\", 'Lisa'),\n                          #(\"hotel/branch[@location = 'California']/staff/age union hotel/branch[@location = 'Las Vegas']/staff/age\", ''),\n                          (\"(//hotel/branch[@location = 'California']/staff/age union //hotel/branch[@location = 'Las Vegas']/staff/age)\", '60'),\n                          (\"(200 to 210)\", \"205\"),\n                          (\"(//hotel/branch[@location = 'California']/staff/age union //hotel/branch[@location = 'Las Vegas']/staff/age)\", \"50\"),\n                          (\"(1, 9, 9, 5)\", \"5\"),\n                          (\"(3, (), (14, 15), 92, 653)\", \"653\"),\n                          (\"for $i in /hotel/branch/staff return $i/given_name\", \"Christopher\"),\n                          (\"for $i in //hotel/branch/staff return $i/given_name\", \"Christopher\"),\n                          (\"distinct-values(for $i in /hotel/branch/staff return $i/given_name)\", \"Jessica\"),\n                          (\"distinct-values(for $i in //hotel/branch/staff return $i/given_name)\", \"Jessica\"),\n                          (\"for $i in (7 to  15) return $i*10\", \"130\"),\n                          (\"some $i in /hotel/branch/staff satisfies $i/age < 20\", \"false\"),\n                          (\"some $i in //hotel/branch/staff satisfies $i/age < 20\", \"false\"),\n                          (\"every $i in /hotel/branch/staff satisfies $i/age > 20\", \"true\"),\n                          (\"every $i in //hotel/branch/staff satisfies $i/age > 20 \", \"true\"),\n                          (\"let $x := branch[@location = 'California'], $y := branch[@location = 'Las Vegas'] return (avg($x/staff/age), avg($y/staff/age))\", \"27.5\"),\n                          (\"let $x := //branch[@location = 'California'], $y := //branch[@location = 'Las Vegas'] return (avg($x/staff/age), avg($y/staff/age))\", \"27.5\"),\n                          (\"let $nu := 1, $de := 1000 return  'probability = ' || $nu div $de * 100 || '%'\", \"0.1%\"),\n                          (\"let $nu := 2, $probability := function ($argument) { 'probability = ' ||  $nu div $argument  * 100 || '%'}, $de := 5 return $probability($de)\", \"40%\"),\n                          (\"'XPATH2.0-3.1 dissemination' instance of xs:string \", \"true\"),\n                          (\"'new stackoverflow question incoming' instance of xs:integer \", \"false\"),\n                          (\"'50000' cast as xs:integer\", \"50000\"),\n                          (\"//branch[@location = 'California']/staff[1]/surname eq 'Anderson'\", \"true\"),\n                          (\"fn:false()\", \"false\")])\ndef test_hotels(html_content, xpath, answer):\n    html_content = html_tools.xpath_filter(xpath, html_content, append_pretty_line_formatting=True)\n    assert type(html_content) == str\n    assert answer in html_content\n\n\n\nbranches_to_visit = \"\"\"<?xml version=\"1.0\" ?>\n  <branches_to_visit>\n     <manager name=\"Godot\" room_no=\"501\">\n         <branch>Area 51</branch>\n         <branch>A place with no name</branch>\n         <branch>Stalsk12</branch>\n     </manager>\n      <manager name=\"Freya\" room_no=\"305\">\n         <branch>Stalsk12</branch>\n         <branch>Barcelona</branch>\n         <branch>Paris</branch>\n     </manager>\n </branches_to_visit>\"\"\"\n@pytest.mark.parametrize(\"html_content\", [branches_to_visit])\n@pytest.mark.parametrize(\"xpath, answer\", [\n    (\"manager[@name = 'Godot']/branch union manager[@name = 'Freya']/branch\", \"Area 51\"),\n    (\"//manager[@name = 'Godot']/branch union //manager[@name = 'Freya']/branch\", \"Stalsk12\"),\n    (\"manager[@name = 'Godot']/branch | manager[@name = 'Freya']/branch\", \"Stalsk12\"),\n    (\"//manager[@name = 'Godot']/branch | //manager[@name = 'Freya']/branch\", \"Stalsk12\"),\n    (\"manager/branch intersect manager[@name = 'Godot']/branch\", \"A place with no name\"),\n    (\"//manager/branch intersect //manager[@name = 'Godot']/branch\", \"A place with no name\"),\n    (\"manager[@name = 'Godot']/branch intersect manager[@name = 'Freya']/branch\", \"\"),\n    (\"manager/branch except manager[@name = 'Godot']/branch\", \"Barcelona\"),\n    (\"manager[@name = 'Godot']/branch[1]  eq 'Area 51'\", \"true\"),\n    (\"//manager[@name = 'Godot']/branch[1]  eq 'Area 51'\", \"true\"),\n    (\"manager[@name = 'Godot']/branch[1]  eq 'Seoul'\", \"false\"),\n    (\"//manager[@name = 'Godot']/branch[1]  eq 'Seoul'\", \"false\"),\n    (\"manager[@name = 'Godot']/branch[2] eq manager[@name = 'Freya']/branch[2]\", \"false\"),\n    (\"//manager[@name = 'Godot']/branch[2] eq //manager[@name = 'Freya']/branch[2]\", \"false\"),\n    (\"manager[1]/@room_no lt manager[2]/@room_no\", \"false\"),\n    (\"//manager[1]/@room_no lt //manager[2]/@room_no\", \"false\"),\n    (\"manager[1]/@room_no gt manager[2]/@room_no\", \"true\"),\n    (\"//manager[1]/@room_no gt //manager[2]/@room_no\", \"true\"),\n    (\"manager[@name = 'Godot']/branch[1]  = 'Area 51'\", \"true\"),\n    (\"//manager[@name = 'Godot']/branch[1]  = 'Area 51'\", \"true\"),\n    (\"manager[@name = 'Godot']/branch[1]  = 'Seoul'\", \"false\"),\n    (\"//manager[@name = 'Godot']/branch[1]  = 'Seoul'\", \"false\"),\n    (\"manager[@name = 'Godot']/branch  = 'Area 51'\", \"true\"),\n    (\"//manager[@name = 'Godot']/branch  = 'Area 51'\", \"true\"),\n    (\"manager[@name = 'Godot']/branch  = 'Barcelona'\", \"false\"),\n    (\"//manager[@name = 'Godot']/branch  = 'Barcelona'\", \"false\"),\n    (\"manager[1]/@room_no > manager[2]/@room_no\", \"true\"),\n    (\"//manager[1]/@room_no > //manager[2]/@room_no\", \"true\"),\n    (\"manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is manager[1]/branch[1]\", \"false\"),\n    (\"//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is //manager[1]/branch[1]\", \"false\"),\n    (\"manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is manager[1]/branch[3]\", \"true\"),\n    (\"//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is //manager[1]/branch[3]\", \"true\"),\n    (\"manager[@name = 'Godot']/branch[ . = 'Stalsk12'] <<  manager[1]/branch[1]\", \"false\"),\n    (\"//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] <<  //manager[1]/branch[1]\", \"false\"),\n    (\"manager[@name = 'Godot']/branch[ . = 'Stalsk12']  >>  manager[1]/branch[1]\", \"true\"),\n    (\"//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] >>  //manager[1]/branch[1]\", \"true\"),\n    (\"manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is manager[@name = 'Freya']/branch[ . = 'Stalsk12']\", \"false\"),\n    (\"//manager[@name = 'Godot']/branch[ . = 'Stalsk12'] is //manager[@name = 'Freya']/branch[ . = 'Stalsk12']\", \"false\"),\n    (\"manager[1]/@name || manager[2]/@name\", \"GodotFreya\"),\n    (\"//manager[1]/@name || //manager[2]/@name\", \"GodotFreya\"),\n                          ])\ndef test_branches_to_visit(html_content, xpath, answer):\n    html_content = html_tools.xpath_filter(xpath, html_content, append_pretty_line_formatting=True)\n    assert type(html_content) == str\n    assert answer in html_content\n\ntrips = \"\"\"\n<trips>\n   <trip reservation_number=\"10\">\n       <depart>2023-10-06</depart>\n       <arrive>2023-10-10</arrive>\n       <traveler name=\"Christopher Anderson\">\n           <duration>4</duration>\n           <price>2000.00</price>\n       </traveler>\n   </trip>\n   <trip reservation_number=\"12\">\n       <depart>2023-10-06</depart>\n       <arrive>2023-10-12</arrive>\n       <traveler name=\"Frank Carter\">\n           <duration>6</duration>\n           <price>3500.34</price>\n       </traveler>\n   </trip>\n</trips>\"\"\"\n@pytest.mark.parametrize(\"html_content\", [trips])\n@pytest.mark.parametrize(\"xpath, answer\", [\n    (\"1 + 9 * 9 + 5 div 5\", \"83\"),\n    (\"(1 + 9 * 9 + 5) div 6\", \"14.5\"),\n    (\"23 idiv 3\", \"7\"),\n    (\"23 div 3\", \"7.66666666\"),\n    (\"for $i in ./trip return $i/traveler/duration * $i/traveler/price\", \"21002.04\"),\n    (\"for $i in ./trip return $i/traveler/duration \", \"4\"),\n    (\"for $i in .//trip return $i/traveler/duration * $i/traveler/price\", \"21002.04\"),\n    (\"sum(for $i in ./trip return $i/traveler/duration * $i/traveler/price)\", \"29002.04\"),\n    (\"sum(for $i in .//trip return $i/traveler/duration * $i/traveler/price)\", \"29002.04\"),\n    #(\"trip[1]/depart - trip[1]/arrive\", \"fail_to_get_answer\"),\n    #(\"//trip[1]/depart - //trip[1]/arrive\", \"fail_to_get_answer\"),\n    #(\"trip[1]/depart + trip[1]/arrive\", \"fail_to_get_answer\"),\n    #(\"xs:date(trip[1]/depart) + xs:date(trip[1]/arrive)\", \"fail_to_get_answer\"),\n    (\"(//trip[1]/arrive cast as xs:date) - (//trip[1]/depart cast as xs:date)\", \"P4D\"),\n    (\"(//trip[1]/depart cast as xs:date) - (//trip[1]/arrive cast as xs:date)\", \"-P4D\"),\n    (\"(//trip[1]/depart cast as xs:date) + xs:dayTimeDuration('P3D')\", \"2023-10-09\"),\n    (\"(//trip[1]/depart cast as xs:date) - xs:dayTimeDuration('P3D')\", \"2023-10-03\"),\n    (\"(456, 623) instance of xs:integer\", \"false\"),\n    (\"(456, 623) instance of xs:integer*\", \"true\"),\n    (\"/trips/trip instance of element()\", \"false\"),\n    (\"/trips/trip instance of element()*\", \"true\"),\n    (\"/trips/trip[1]/arrive instance of xs:date\", \"false\"),\n    (\"date(/trips/trip[1]/arrive) instance of xs:date\", \"true\"),\n    (\"'8' cast as xs:integer\", \"8\"),\n    (\"'11.1E3' cast as xs:double\", \"11100\"),\n    (\"6.5 cast as xs:integer\", \"6\"),\n    #(\"/trips/trip[1]/arrive cast as xs:dateTime\", \"fail_to_get_answer\"),\n    (\"/trips/trip[1]/arrive cast as xs:date\", \"2023-10-10\"),\n    (\"('2023-10-12') cast as xs:date\", \"2023-10-12\"),\n    (\"for $i in //trip return concat($i/depart, '  ', $i/arrive)\", \"2023-10-06  2023-10-10\"),\n                          ])\ndef test_trips(html_content, xpath, answer):\n    html_content = html_tools.xpath_filter(xpath, html_content, append_pretty_line_formatting=True)\n    assert type(html_content) == str\n    assert answer in html_content\n\n\n# Test for UTF-8 encoding bug fix (issue #3658)\n# Polish and other UTF-8 characters should be preserved correctly\npolish_html = \"\"\"<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"></head>\n<body>\n<div class=\"index--s-headline-link\">\n    <a class=\"index--s-headline-link\" href=\"#\">\n        Naukowcy potwierdzają: oglądanie krótkich filmików prowadzi do \"zgnilizny mózgu\"\n    </a>\n</div>\n<div>\n    <a class=\"other-class\" href=\"#\">\n        Test with Polish chars: żółć ąę śń\n    </a>\n</div>\n<div>\n    <p class=\"unicode-test\">Cyrillic: Привет мир</p>\n    <p class=\"unicode-test\">Greek: Γειά σου κόσμε</p>\n    <p class=\"unicode-test\">Arabic: مرحبا بالعالم</p>\n    <p class=\"unicode-test\">Chinese: 你好世界</p>\n    <p class=\"unicode-test\">Japanese: こんにちは世界</p>\n    <p class=\"unicode-test\">Emoji: 🌍🎉✨</p>\n</div>\n</body>\n</html>\n\"\"\"\n\n\n@pytest.mark.parametrize(\"html_content\", [polish_html])\n@pytest.mark.parametrize(\"xpath, expected_text\", [\n    # Test Polish characters in xpath_filter\n    ('//a[(contains(@class,\"index--s-headline-link\"))]', 'Naukowcy potwierdzają'),\n    ('//a[(contains(@class,\"index--s-headline-link\"))]', 'oglądanie krótkich filmików'),\n    ('//a[(contains(@class,\"index--s-headline-link\"))]', 'zgnilizny mózgu'),\n    ('//a[@class=\"other-class\"]', 'żółć ąę śń'),\n\n    # Test various Unicode scripts\n    ('//p[@class=\"unicode-test\"]', 'Привет мир'),\n    ('//p[@class=\"unicode-test\"]', 'Γειά σου κόσμε'),\n    ('//p[@class=\"unicode-test\"]', 'مرحبا بالعالم'),\n    ('//p[@class=\"unicode-test\"]', '你好世界'),\n    ('//p[@class=\"unicode-test\"]', 'こんにちは世界'),\n    ('//p[@class=\"unicode-test\"]', '🌍🎉✨'),\n\n    # Test with text() extraction\n    ('//a[@class=\"other-class\"]/text()', 'żółć'),\n])\ndef test_xpath_utf8_encoding(html_content, xpath, expected_text):\n    \"\"\"Test that XPath filters preserve UTF-8 characters correctly (issue #3658)\"\"\"\n    result = html_tools.xpath_filter(xpath, html_content, append_pretty_line_formatting=False)\n    assert type(result) == str\n    assert expected_text in result\n    # Ensure characters are NOT HTML-entity encoded\n    # For example, 'ą' should NOT become '&#261;'\n    assert '&#' not in result or expected_text in result\n\n\n@pytest.mark.parametrize(\"html_content\", [polish_html])\n@pytest.mark.parametrize(\"xpath, expected_text\", [\n    # Test Polish characters in xpath1_filter\n    ('//a[(contains(@class,\"index--s-headline-link\"))]', 'Naukowcy potwierdzają'),\n    ('//a[(contains(@class,\"index--s-headline-link\"))]', 'mózgu'),\n    ('//a[@class=\"other-class\"]', 'żółć ąę śń'),\n\n    # Test various Unicode scripts with xpath1\n    ('//p[@class=\"unicode-test\" and contains(text(), \"Cyrillic\")]', 'Привет мир'),\n    ('//p[@class=\"unicode-test\" and contains(text(), \"Greek\")]', 'Γειά σου'),\n    ('//p[@class=\"unicode-test\" and contains(text(), \"Chinese\")]', '你好世界'),\n])\ndef test_xpath1_utf8_encoding(html_content, xpath, expected_text):\n    \"\"\"Test that XPath1 filters preserve UTF-8 characters correctly\"\"\"\n    result = html_tools.xpath1_filter(xpath, html_content, append_pretty_line_formatting=False)\n    assert type(result) == str\n    assert expected_text in result\n    # Ensure characters are NOT HTML-entity encoded\n    assert '&#' not in result or expected_text in result\n\n\n# Test with real-world example from wyborcza.pl (issue #3658)\nwyborcza_style_html = \"\"\"<!DOCTYPE html>\n<html lang=\"pl\">\n<head><meta charset=\"utf-8\"></head>\n<body>\n<div class=\"article-list\">\n    <a class=\"index--s-headline-link\" href=\"/article1\">\n        Naukowcy potwierdzają: oglądanie krótkich filmików prowadzi do \"zgnilizny mózgu\"\n    </a>\n    <a class=\"index--s-headline-link\" href=\"/article2\">\n        Zmiany klimatyczne wpływają na życie w miastach\n    </a>\n    <a class=\"index--s-headline-link\" href=\"/article3\">\n        Łódź: Nowe inwestycje w infrastrukturę miejską\n    </a>\n</div>\n</body>\n</html>\n\"\"\"\n\n\ndef test_wyborcza_real_world_example():\n    \"\"\"Test real-world case from wyborcza.pl that was failing (issue #3658)\"\"\"\n    xpath = '//a[(contains(@class,\"index--s-headline-link\"))]'\n    result = html_tools.xpath_filter(xpath, wyborcza_style_html, append_pretty_line_formatting=False)\n\n    # These exact strings should appear in the result\n    assert 'Naukowcy potwierdzają' in result\n    assert 'oglądanie krótkich filmików' in result\n    assert 'zgnilizny mózgu' in result\n    assert 'Łódź' in result\n\n    # Make sure they're NOT corrupted to mojibake like \"potwierdzajÄ\"\n    assert 'potwierdzajÄ' not in result\n    assert 'ogl&#261;danie' not in result\n    assert 'm&#243;zgu' not in result\n"
  },
  {
    "path": "changedetectionio/tests/unit/__init__.py",
    "content": "\"\"\"Unit tests for the app.\"\"\"\n"
  },
  {
    "path": "changedetectionio/tests/unit/test-content/README.md",
    "content": "# What is this?\nThis is test content for the python diff engine, we use the JS interface for the front end, because you can explore \ndifferences in words etc, but we use (at the moment) the python difflib engine.\n\nThis content `before.txt` and `after.txt` is for unit testing\n"
  },
  {
    "path": "changedetectionio/tests/unit/test-content/after-2.txt",
    "content": "After twenty years, as cursed as I may be\nok\nand insure that I'm one of those computer nerds.\n"
  },
  {
    "path": "changedetectionio/tests/unit/test-content/after.txt",
    "content": "After twenty years, as cursed as I may be\nfor having learned computerese,\nI continue to examine bits, bytes and words\nxok\nnext-x-ok\nand insure that I'm one of those computer nerds.\nand something new"
  },
  {
    "path": "changedetectionio/tests/unit/test-content/before.txt",
    "content": "After twenty years, as cursed as I may be\nfor having learned computerese,\nI continue to examine bits, bytes and words\nok\nand insure that I'm one of those computer nerds.\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_conditions.py",
    "content": "from changedetectionio.conditions import execute_ruleset_against_all_plugins\nfrom changedetectionio.model import CONDITIONS_MATCH_LOGIC_DEFAULT\nfrom changedetectionio.store import ChangeDetectionStore\nimport shutil\nimport tempfile\nimport time\nimport unittest\nimport uuid\n\n\nclass TestTriggerConditions(unittest.TestCase):\n    def setUp(self):\n\n        # Create a temporary directory for the test datastore\n        self.test_datastore_path = tempfile.mkdtemp()\n\n        # Initialize ChangeDetectionStore with our test path and no default watches\n        self.store = ChangeDetectionStore(\n            datastore_path=self.test_datastore_path,\n            include_default_watches=False\n        )\n\n        # Add a test watch\n        watch_url = \"https://example.com\"\n        self.watch_uuid = self.store.add_watch(url=watch_url)\n\n    def tearDown(self):\n      # Clean up the test datastore\n      self.store.stop_thread = True\n      time.sleep(0.5)  # Give thread time to stop\n      shutil.rmtree(self.test_datastore_path)\n\n    def test_conditions_execution_pass(self):\n        # Get the watch object\n        watch = self.store.data['watching'][self.watch_uuid]\n\n        # Create and save a snapshot\n        first_content = \"I saw 100 people at a rock show\"\n        timestamp1 = int(time.time())\n        snapshot_id1 = str(uuid.uuid4())\n        watch.save_history_blob(contents=first_content,\n                                timestamp=timestamp1,\n                                snapshot_id=snapshot_id1)\n\n        # Add another snapshot\n        second_content = \"I saw 200 people at a rock show\"\n        timestamp2 = int(time.time()) + 60\n        snapshot_id2 = str(uuid.uuid4())\n        watch.save_history_blob(contents=second_content,\n                                timestamp=timestamp2,\n                                snapshot_id=snapshot_id2)\n\n        # Verify both snapshots are stored\n        history = watch.history\n        self.assertEqual(len(history), 2)\n\n        # Retrieve and check snapshots\n        #snapshot1 = watch.get_history_snapshot(timestamp=str(timestamp1))\n        #snapshot2 = watch.get_history_snapshot(timestamp=str(timestamp2))\n\n        self.store.data['watching'][self.watch_uuid].update(\n            {\n                \"conditions_match_logic\": CONDITIONS_MATCH_LOGIC_DEFAULT,\n                \"conditions\": [\n                    {\"operator\": \">=\", \"field\": \"extracted_number\", \"value\": \"10\"},\n                    {\"operator\": \"<=\", \"field\": \"extracted_number\", \"value\": \"5000\"},\n                    {\"operator\": \"in\", \"field\": \"page_text\", \"value\": \"rock\"},\n                    #{\"operator\": \"starts_with\", \"field\": \"page_text\", \"value\": \"I saw\"},\n                ]\n            }\n        )\n\n        # ephemeral_data - some data that could exist before the watch saved a new version\n        result = execute_ruleset_against_all_plugins(current_watch_uuid=self.watch_uuid,\n                                                     application_datastruct=self.store.data,\n                                                     ephemeral_data={'text': \"I saw 500 people at a rock show\"})\n\n        # @todo - now we can test that 'Extract number' increased more than X since last time\n        self.assertTrue(result.get('result'))\n\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_html_to_text.py",
    "content": "#!/usr/bin/env python3\n# coding=utf-8\n\n\"\"\"Unit tests for html_tools.html_to_text function.\"\"\"\n\nimport hashlib\nimport threading\nimport unittest\nfrom queue import Queue\n\nfrom changedetectionio.html_tools import html_to_text\n\n\nclass TestHtmlToText(unittest.TestCase):\n    \"\"\"Test html_to_text function for correctness and thread-safety.\"\"\"\n\n    def test_basic_text_extraction(self):\n        \"\"\"Test basic HTML to text conversion.\"\"\"\n        html = '<html><body><h1>Title</h1><p>Paragraph text.</p></body></html>'\n        text = html_to_text(html)\n\n        assert 'Title' in text\n        assert 'Paragraph text.' in text\n        assert '<' not in text  # HTML tags should be stripped\n        assert '>' not in text\n\n    def test_empty_html(self):\n        \"\"\"Test handling of empty HTML.\"\"\"\n        html = '<html><body></body></html>'\n        text = html_to_text(html)\n\n        # Should return empty or whitespace only\n        assert text.strip() == ''\n\n    def test_nested_elements(self):\n        \"\"\"Test extraction from nested HTML elements.\"\"\"\n        html = '''\n        <html>\n            <body>\n                <div>\n                    <h1>Header</h1>\n                    <div>\n                        <p>First paragraph</p>\n                        <p>Second paragraph</p>\n                    </div>\n                </div>\n            </body>\n        </html>\n        '''\n        text = html_to_text(html)\n\n        assert 'Header' in text\n        assert 'First paragraph' in text\n        assert 'Second paragraph' in text\n\n    def test_anchor_tag_rendering(self):\n        \"\"\"Test anchor tag rendering option.\"\"\"\n        html = '<html><body><a href=\"https://example.com\">Link text</a></body></html>'\n\n        # Without rendering anchors\n        text_without = html_to_text(html, render_anchor_tag_content=False)\n        assert 'Link text' in text_without\n        assert 'https://example.com' not in text_without\n\n        # With rendering anchors\n        text_with = html_to_text(html, render_anchor_tag_content=True)\n        assert 'Link text' in text_with\n        assert 'https://example.com' in text_with or '[Link text]' in text_with\n\n    def test_rss_mode(self):\n        \"\"\"Test RSS mode converts title tags to h1.\"\"\"\n        html = '<item><title>RSS Title</title><description>Content</description></item>'\n\n        # is_rss=True should convert <title> to <h1>\n        text = html_to_text(html, is_rss=True)\n\n        assert 'RSS Title' in text\n        assert 'Content' in text\n\n    def test_special_characters(self):\n        \"\"\"Test handling of special characters and entities.\"\"\"\n        html = '<html><body><p>Test &amp; &lt;special&gt; characters</p></body></html>'\n        text = html_to_text(html)\n\n        # Entities should be decoded\n        assert 'Test &' in text or 'Test &amp;' in text\n        assert 'special' in text\n\n    def test_whitespace_handling(self):\n        \"\"\"Test that whitespace is properly handled.\"\"\"\n        html = '<html><body><p>Line 1</p><p>Line 2</p></body></html>'\n        text = html_to_text(html)\n\n        # Should have some separation between lines\n        assert 'Line 1' in text\n        assert 'Line 2' in text\n        assert text.count('\\n') >= 1  # At least one newline\n\n    def test_deterministic_output(self):\n        \"\"\"Test that the same HTML always produces the same text.\"\"\"\n        html = '<html><body><h1>Test</h1><p>Content here</p></body></html>'\n\n        # Extract text multiple times\n        results = [html_to_text(html) for _ in range(10)]\n\n        # All results should be identical\n        assert len(set(results)) == 1, \"html_to_text should be deterministic\"\n\n    def test_thread_safety_determinism(self):\n        \"\"\"\n        Test that html_to_text produces deterministic output under high concurrency.\n\n        This verifies that lxml's default parser (used by inscriptis.get_text)\n        is thread-safe and produces consistent results when called from multiple\n        threads simultaneously.\n        \"\"\"\n        html = '''\n        <html>\n            <head><title>Test Page</title></head>\n            <body>\n                <h1>Main Heading</h1>\n                <div class=\"content\">\n                    <p>First paragraph with <b>bold text</b>.</p>\n                    <p>Second paragraph with <i>italic text</i>.</p>\n                    <ul>\n                        <li>Item 1</li>\n                        <li>Item 2</li>\n                        <li>Item 3</li>\n                    </ul>\n                </div>\n            </body>\n        </html>\n        '''\n\n        results_queue = Queue()\n\n        def worker(worker_id, iterations=10):\n            \"\"\"Worker that converts HTML to text multiple times.\"\"\"\n            for i in range(iterations):\n                text = html_to_text(html)\n                md5 = hashlib.md5(text.encode('utf-8')).hexdigest()\n                results_queue.put((worker_id, i, md5))\n\n        # Launch many threads simultaneously\n        num_threads = 50\n        threads = []\n\n        for i in range(num_threads):\n            t = threading.Thread(target=worker, args=(i,))\n            threads.append(t)\n            t.start()\n\n        # Wait for all threads to complete\n        for t in threads:\n            t.join()\n\n        # Collect all MD5 results\n        md5_values = []\n        while not results_queue.empty():\n            _, _, md5 = results_queue.get()\n            md5_values.append(md5)\n\n        # All MD5s should be identical\n        unique_md5s = set(md5_values)\n\n        assert len(unique_md5s) == 1, (\n            f\"Thread-safety issue detected! Found {len(unique_md5s)} different MD5 values: {unique_md5s}. \"\n            \"The thread-local parser fix may not be working correctly.\"\n        )\n\n        print(f\"✓ Thread-safety test passed: {len(md5_values)} conversions, all identical\")\n\n    def test_thread_safety_basic(self):\n        \"\"\"Verify basic thread safety - multiple threads can call html_to_text simultaneously.\"\"\"\n        results = []\n        errors = []\n\n        def worker():\n            \"\"\"Worker that converts HTML to text.\"\"\"\n            try:\n                html = '<html><body><h1>Test</h1><p>Content</p></body></html>'\n                text = html_to_text(html)\n                results.append(text)\n            except Exception as e:\n                errors.append(e)\n\n        # Launch 10 threads simultaneously\n        threads = [threading.Thread(target=worker) for _ in range(10)]\n        for t in threads:\n            t.start()\n        for t in threads:\n            t.join()\n\n        # Should have no errors\n        assert len(errors) == 0, f\"Thread-safety errors occurred: {errors}\"\n\n        # All results should be identical\n        assert len(set(results)) == 1, \"All threads should produce identical output\"\n\n        print(f\"✓ Basic thread-safety test passed: {len(results)} threads, no errors\")\n\n    def test_large_html_with_bloated_head(self):\n        \"\"\"\n        Test that html_to_text can handle large HTML documents with massive <head> bloat.\n\n        SPAs often dump 10MB+ of styles, scripts, and other bloat into the <head> section.\n        This can cause inscriptis to silently exit when processing very large documents.\n        The fix strips <style>, <script>, <svg>, <noscript>, <link>, <meta>, and HTML comments\n        before processing, allowing extraction of actual body content.\n        \"\"\"\n        # Generate massive style block (~5MB)\n        large_style = '<style>' + '.class{color:red;}\\n' * 200000 + '</style>\\n'\n\n        # Generate massive script block (~5MB)\n        large_script = '<script>' + 'console.log(\"bloat\");\\n' * 200000 + '</script>\\n'\n\n        # Generate lots of SVG bloat (~3MB)\n        svg_bloat = '<svg><path d=\"M0,0 L100,100\"/></svg>\\n' * 50000\n\n        # Generate meta/link tags (~2MB)\n        meta_bloat = '<meta name=\"description\" content=\"bloat\"/>\\n' * 50000\n        link_bloat = '<link rel=\"stylesheet\" href=\"bloat.css\"/>\\n' * 50000\n\n        # Generate HTML comments (~1MB)\n        comment_bloat = '<!-- This is bloat -->\\n' * 50000\n\n        # Generate noscript bloat\n        noscript_bloat = '<noscript>Enable JavaScript</noscript>\\n' * 10000\n\n        # Build the large HTML document\n        html = f'''<!DOCTYPE html>\n<html>\n<head>\n    <title>Test Page</title>\n    {large_style}\n    {large_script}\n    {svg_bloat}\n    {meta_bloat}\n    {link_bloat}\n    {comment_bloat}\n    {noscript_bloat}\n</head>\n<body>\n    <h1>Important Heading</h1>\n    <p>This is the actual content that should be extracted.</p>\n    <div>\n        <p>First paragraph with meaningful text.</p>\n        <p>Second paragraph with more content.</p>\n    </div>\n    <footer>Footer text</footer>\n</body>\n</html>\n'''\n\n        # Verify the HTML is actually large (should be ~20MB+)\n        html_size_mb = len(html) / (1024 * 1024)\n        assert html_size_mb > 15, f\"HTML should be >15MB, got {html_size_mb:.2f}MB\"\n\n        print(f\"  Testing {html_size_mb:.2f}MB HTML document with bloated head...\")\n\n        # This should not crash or silently exit\n        text = html_to_text(html)\n\n        # Verify we got actual text output (not empty/None)\n        assert text is not None, \"html_to_text returned None\"\n        assert len(text) > 0, \"html_to_text returned empty string\"\n\n        # Verify the actual body content was extracted\n        assert 'Important Heading' in text, \"Failed to extract heading\"\n        assert 'actual content that should be extracted' in text, \"Failed to extract paragraph\"\n        assert 'First paragraph with meaningful text' in text, \"Failed to extract first paragraph\"\n        assert 'Second paragraph with more content' in text, \"Failed to extract second paragraph\"\n        assert 'Footer text' in text, \"Failed to extract footer\"\n\n        # Verify bloat was stripped (output should be tiny compared to input)\n        text_size_kb = len(text) / 1024\n        assert text_size_kb < 1, f\"Output too large ({text_size_kb:.2f}KB), bloat not stripped\"\n\n        # Verify no CSS, script content, or SVG leaked through\n        assert 'color:red' not in text, \"Style content leaked into text output\"\n        assert 'console.log' not in text, \"Script content leaked into text output\"\n        assert '<path' not in text, \"SVG content leaked into text output\"\n        assert 'bloat.css' not in text, \"Link href leaked into text output\"\n\n        print(f\"  ✓ Successfully processed {html_size_mb:.2f}MB HTML -> {text_size_kb:.2f}KB text\")\n\n    def test_body_display_none_spa_pattern(self):\n        \"\"\"\n        Test that html_to_text can extract content from pages with display:none body.\n\n        SPAs (Single Page Applications) often use <body style=\"display:none\"> to hide content\n        until JavaScript loads and renders the page. inscriptis respects CSS display rules,\n        so without preprocessing, it would skip all content and return only newlines.\n\n        The fix strips display:none and visibility:hidden styles from the body tag before\n        processing, allowing text extraction from client-side rendered applications.\n        \"\"\"\n        # Test case 1: Basic display:none\n        html1 = '''<!DOCTYPE html>\n<html lang=\"en\">\n<head><title>What's New – Fluxguard</title></head>\n<body style=\"display:none\">\n    <h1>Important Heading</h1>\n    <p>This is actual content that should be extracted.</p>\n    <div>\n        <p>First paragraph with meaningful text.</p>\n        <p>Second paragraph with more content.</p>\n    </div>\n</body>\n</html>'''\n\n        text1 = html_to_text(html1)\n\n        # Before fix: would return ~33 newlines, len(text) ~= 33\n        # After fix: should extract actual content, len(text) > 100\n        assert len(text1) > 100, f\"Expected substantial text output, got {len(text1)} chars\"\n        assert 'Important Heading' in text1, \"Failed to extract heading from display:none body\"\n        assert 'actual content' in text1, \"Failed to extract paragraph from display:none body\"\n        assert 'First paragraph' in text1, \"Failed to extract nested content\"\n\n        # Should not be mostly newlines\n        newline_ratio = text1.count('\\n') / len(text1)\n        assert newline_ratio < 0.5, f\"Output is mostly newlines ({newline_ratio:.2%}), content not extracted\"\n\n        # Test case 2: visibility:hidden (another hiding pattern)\n        html2 = '<html><body style=\"visibility:hidden\"><h1>Hidden Content</h1><p>Test paragraph.</p></body></html>'\n        text2 = html_to_text(html2)\n\n        assert 'Hidden Content' in text2, \"Failed to extract content from visibility:hidden body\"\n        assert 'Test paragraph' in text2, \"Failed to extract paragraph from visibility:hidden body\"\n\n        # Test case 3: Mixed styles (display:none with other CSS)\n        html3 = '<html><body style=\"color: red; display:none; font-size: 12px\"><p>Mixed style content</p></body></html>'\n        text3 = html_to_text(html3)\n\n        assert 'Mixed style content' in text3, \"Failed to extract content from body with mixed styles\"\n\n        # Test case 4: Case insensitivity (DISPLAY:NONE uppercase)\n        html4 = '<html><body style=\"DISPLAY:NONE\"><p>Uppercase style</p></body></html>'\n        text4 = html_to_text(html4)\n\n        assert 'Uppercase style' in text4, \"Failed to handle uppercase DISPLAY:NONE\"\n\n        # Test case 5: Space variations (display: none vs display:none)\n        html5 = '<html><body style=\"display: none\"><p>With spaces</p></body></html>'\n        text5 = html_to_text(html5)\n\n        assert 'With spaces' in text5, \"Failed to handle 'display: none' with space\"\n\n        # Test case 6: Body with other attributes (class, id)\n        html6 = '<html><body class=\"foo\" style=\"display:none\" id=\"bar\"><p>With attributes</p></body></html>'\n        text6 = html_to_text(html6)\n\n        assert 'With attributes' in text6, \"Failed to extract from body with multiple attributes\"\n\n        # Test case 7: Should NOT affect opacity:0 (which doesn't hide from inscriptis)\n        html7 = '<html><body style=\"opacity:0\"><p>Transparent content</p></body></html>'\n        text7 = html_to_text(html7)\n\n        # Opacity doesn't affect inscriptis text extraction, content should be there\n        assert 'Transparent content' in text7, \"Incorrectly stripped opacity:0 style\"\n\n        print(\"  ✓ All display:none body tag tests passed\")\n\n    def test_style_tag_with_svg_data_uri(self):\n        \"\"\"\n        Test that style tags containing SVG data URIs are properly stripped.\n\n        Some WordPress and modern sites embed SVG as data URIs in CSS, which contains\n        <svg> and </svg> tags within the style content. The regex must use backreferences\n        to ensure <style> matches </style> (not </svg> inside the CSS).\n\n        This was causing errors where the regex would match <style> and stop at the first\n        </svg> it encountered inside a CSS data URI, breaking the HTML structure.\n        \"\"\"\n        # Real-world example from WordPress wp-block-image styles\n        html = '''<!DOCTYPE html>\n<html>\n<head>\n    <style id='wp-block-image-inline-css'>\n.wp-block-image>a,.wp-block-image>figure>a{display:inline-block}.wp-block-image img{box-sizing:border-box;height:auto;max-width:100%;vertical-align:bottom}@supports ((-webkit-mask-image:none) or (mask-image:none)) or (-webkit-mask-image:none){.wp-block-image.is-style-circle-mask img{border-radius:0;-webkit-mask-image:url('data:image/svg+xml;utf8,<svg viewBox=\"0 0 100 100\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"50\" cy=\"50\" r=\"50\"/></svg>');mask-image:url('data:image/svg+xml;utf8,<svg viewBox=\"0 0 100 100\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"50\" cy=\"50\" r=\"50\"/></svg>');mask-mode:alpha}}\n    </style>\n</head>\n<body>\n    <h1>Test Heading</h1>\n    <p>This is the actual content that should be extracted.</p>\n    <div class=\"wp-block-image\">\n        <img src=\"test.jpg\" alt=\"Test image\">\n    </div>\n</body>\n</html>'''\n\n        # This should not crash and should extract the body content\n        text = html_to_text(html)\n\n        # Verify the actual body content was extracted\n        assert text is not None, \"html_to_text returned None\"\n        assert len(text) > 0, \"html_to_text returned empty string\"\n        assert 'Test Heading' in text, \"Failed to extract heading\"\n        assert 'actual content that should be extracted' in text, \"Failed to extract paragraph\"\n\n        # Verify CSS content was stripped (including the SVG data URI)\n        assert '.wp-block-image' not in text, \"CSS class selector leaked into text\"\n        assert 'mask-image' not in text, \"CSS property leaked into text\"\n        assert 'data:image/svg+xml' not in text, \"SVG data URI leaked into text\"\n        assert 'viewBox' not in text, \"SVG attributes leaked into text\"\n\n        # Verify no broken HTML structure\n        assert '<style' not in text, \"Unclosed style tag in output\"\n        assert '</svg>' not in text, \"SVG closing tag leaked into text\"\n\n        print(\"  ✓ Style tag with SVG data URI test passed\")\n\n    def test_style_tag_closes_correctly(self):\n        \"\"\"\n        Test that each tag type (style, script, svg) closes with the correct closing tag.\n\n        Before the fix, the regex used (?:style|script|svg|noscript) for both opening and\n        closing tags, which meant <style> could incorrectly match </svg> as its closing tag.\n        With backreferences, <style> must close with </style>, <svg> with </svg>, etc.\n        \"\"\"\n        # Test nested tags where incorrect matching would break\n        html = '''<!DOCTYPE html>\n<html>\n<head>\n    <style>\n        body { background: url('data:image/svg+xml,<svg><rect/></svg>'); }\n    </style>\n    <script>\n        const svg = '<svg><path d=\"M0,0\"/></svg>';\n    </script>\n</head>\n<body>\n    <h1>Content</h1>\n    <svg><circle cx=\"50\" cy=\"50\" r=\"40\"/></svg>\n    <p>After SVG</p>\n</body>\n</html>'''\n\n        text = html_to_text(html)\n\n        # Should extract body content\n        assert 'Content' in text, \"Failed to extract heading\"\n        assert 'After SVG' in text, \"Failed to extract content after SVG\"\n\n        # Should strip all style/script/svg content\n        assert 'background:' not in text, \"Style content leaked\"\n        assert 'const svg' not in text, \"Script content leaked\"\n        assert '<circle' not in text, \"SVG element leaked\"\n        assert 'data:image/svg+xml' not in text, \"Data URI leaked\"\n\n        print(\"  ✓ Tag closing validation test passed\")\n\n\n\n    def test_script_with_closing_tag_in_string_does_not_eat_content(self):\n        \"\"\"\n        Script tag containing </script> inside a JS string must not prematurely end the block.\n\n        This is the classic regex failure mode: the old pattern would find the first </script>\n        inside the JS string literal and stop there, leaving the tail of the script block\n        (plus any following content) exposed as raw text. BS4 parses the HTML correctly.\n        \"\"\"\n        html = '''<html><body>\n<p>Before script</p>\n<script>\nvar html = \"<div>foo<\\\\/script><p>bar</p>\";\nvar also = 1;\n</script>\n<p>AFTER SCRIPT</p>\n</body></html>'''\n\n        text = html_to_text(html)\n        assert 'Before script' in text\n        assert 'AFTER SCRIPT' in text\n        # Script internals must not leak\n        assert 'var html' not in text\n        assert 'var also' not in text\n\n    def test_content_sandwiched_between_multiple_body_scripts(self):\n        \"\"\"Content between multiple script/style blocks in the body must all survive.\"\"\"\n        html = '''<html><body>\n<script>var a = 1;</script>\n<p>CONTENT A</p>\n<style>.x { color: red; }</style>\n<p>CONTENT B</p>\n<script>var b = 2;</script>\n<p>CONTENT C</p>\n<style>.y { color: blue; }</style>\n<p>CONTENT D</p>\n</body></html>'''\n\n        text = html_to_text(html)\n        for label in ['CONTENT A', 'CONTENT B', 'CONTENT C', 'CONTENT D']:\n            assert label in text, f\"'{label}' was eaten by script/style stripping\"\n        assert 'var a' not in text\n        assert 'var b' not in text\n        assert 'color: red' not in text\n        assert 'color: blue' not in text\n\n    def test_unicode_and_international_content_preserved(self):\n        \"\"\"Non-ASCII content (umlauts, CJK, soft hyphens) must survive stripping.\"\"\"\n        html = '''<html><body>\n<style>.x{color:red}</style>\n<p>German: Aus\\xadge\\xadbucht! — ANMELDUNG — Fan\\xadday 2026</p>\n<p>Chinese: \\u6ce8\\u518c</p>\n<p>Japanese: \\u767b\\u9332</p>\n<p>Korean: \\ub4f1\\ub85d</p>\n<p>Emoji: \\U0001f4e2</p>\n<script>var x = 1;</script>\n</body></html>'''\n\n        text = html_to_text(html)\n        assert 'ANMELDUNG' in text\n        assert '\\u6ce8\\u518c' in text   # Chinese\n        assert '\\u767b\\u9332' in text   # Japanese\n        assert '\\ub4f1\\ub85d' in text   # Korean\n\n    def test_style_with_type_attribute_is_stripped(self):\n        \"\"\"<style type=\"text/css\"> (with type attribute) must be stripped just like bare <style>.\"\"\"\n        html = '''<html><body>\n<style type=\"text/css\">.important { display: none; }</style>\n<p>VISIBLE CONTENT</p>\n</body></html>'''\n\n        text = html_to_text(html)\n        assert 'VISIBLE CONTENT' in text\n        assert '.important' not in text\n        assert 'display: none' not in text\n\n    def test_ldjson_script_is_stripped(self):\n        \"\"\"<script type=\"application/ld+json\"> must be stripped — raw JSON must not appear as text.\"\"\"\n        html = '''<html><body>\n<script type=\"application/ld+json\">\n{\"@type\": \"Product\", \"name\": \"Widget\", \"price\": \"9.99\"}\n</script>\n<p>PRODUCT PAGE</p>\n</body></html>'''\n\n        text = html_to_text(html)\n        assert 'PRODUCT PAGE' in text\n        assert '@type' not in text\n        assert '\"price\"' not in text\n\n    def test_inline_svg_is_stripped_entirely(self):\n        \"\"\"\n        Inline SVG elements in the body are stripped by BS4 before passing to inscriptis.\n        SVGs can be huge (icon libraries, data visualisations) and produce garbage path-data\n        text. The old regex code explicitly stripped <svg>; the BS4 path must do the same.\n        \"\"\"\n        html = '''<html><body>\n<p>Before SVG</p>\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n    <path d=\"M14 5L7 12L14 19Z\" fill=\"none\"/>\n    <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n</svg>\n<p>After SVG</p>\n</body></html>'''\n\n        text = html_to_text(html)\n        assert 'Before SVG' in text\n        assert 'After SVG' in text\n        assert 'M14 5L7' not in text, \"SVG path data should not appear in text output\"\n        assert 'viewBox' not in text, \"SVG attributes should not appear in text output\"\n\n    def test_tag_inside_json_data_attribute_does_not_eat_content(self):\n        \"\"\"\n        Tags inside JSON data attributes with JS-escaped closing tags must not eat real content.\n\n        Real-world case: Elementor/JetEngine WordPress widgets embed HTML (including SVG icons)\n        inside JSON data attributes like data-slider-atts. The HTML inside is JS-escaped, so\n        closing tags appear as <\\\\/svg> rather than </svg>.\n\n        The old regex approach would find <svg> inside the attribute value, then fail to find\n        <\\/svg> as a matching close tag, and scan forward to the next real </svg> in the DOM —\n        eating tens of kilobytes of actual page content in the process.\n        \"\"\"\n        html = '''<!DOCTYPE html>\n<html>\n<head><title>Test</title></head>\n<body>\n<div class=\"slider\" data-slider-atts=\"{&quot;prevArrow&quot;:&quot;<i class=\\\\&quot;icon\\\\&quot;><svg width=\\\\&quot;24\\\\&quot; height=\\\\&quot;24\\\\&quot; viewBox=\\\\&quot;0 0 24 24\\\\&quot; xmlns=\\\\&quot;http:\\\\/\\\\/www.w3.org\\\\/2000\\\\/svg\\\\&quot;><path d=\\\\&quot;M14 5L7 12L14 19\\\\&quot;\\\\/><\\\\/svg><\\\\/i>&quot;}\">\n</div>\n<div class=\"content\">\n    <h1>IMPORTANT CONTENT</h1>\n    <p>This text must not be eaten by the tag-stripping logic.</p>\n</div>\n<svg><circle cx=\"50\" cy=\"50\" r=\"40\"/></svg>\n</body>\n</html>'''\n\n        text = html_to_text(html)\n\n        assert 'IMPORTANT CONTENT' in text, (\n            \"Content after a JS-escaped tag in a data attribute was incorrectly stripped. \"\n            \"The tag-stripping logic is matching <tag> inside attribute values and scanning \"\n            \"forward to the next real closing tag in the DOM.\"\n        )\n        assert 'This text must not be eaten' in text\n\n    def test_script_inside_json_data_attribute_does_not_eat_content(self):\n        \"\"\"Same issue as above but with <script> embedded in a data attribute with JS-escaped closing tag.\"\"\"\n        html = '''<!DOCTYPE html>\n<html>\n<head><title>Test</title></head>\n<body>\n<div data-config=\"{&quot;template&quot;:&quot;<script type=\\\\&quot;text\\\\/javascript\\\\&quot;>var x=1;<\\\\/script>&quot;}\">\n</div>\n<div>\n    <h1>MUST SURVIVE</h1>\n    <p>Real content after the data attribute with embedded script tag.</p>\n</div>\n<script>var real = 1;</script>\n</body>\n</html>'''\n\n        text = html_to_text(html)\n\n        assert 'MUST SURVIVE' in text, (\n            \"Content after a JS-escaped <script> in a data attribute was incorrectly stripped.\"\n        )\n        assert 'Real content after the data attribute' in text\n\n\nif __name__ == '__main__':\n    # Can run this file directly for quick testing\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_jinja2_security.py",
    "content": "#!/usr/bin/env python3\n\n# run from dir above changedetectionio/ dir\n# python3 -m unittest changedetectionio.tests.unit.test_jinja2_security\n\nimport unittest\nfrom changedetectionio import jinja2_custom as safe_jinja\n\n\n# mostly\nclass TestJinja2SSTI(unittest.TestCase):\n\n    def test_exception(self):\n        import jinja2\n\n        # Where sandbox should kick in\n        attempt_list = [\n            \"My name is {{ self.__init__.__globals__.__builtins__.__import__('os').system('id') }}\",\n            \"{{ self._TemplateReference__context.cycler.__init__.__globals__.os }}\",\n            \"{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}\",\n            \"{{cycler.__init__.__globals__.os.popen('id').read()}}\",\n            \"{{joiner.__init__.__globals__.os.popen('id').read()}}\",\n            \"{{namespace.__init__.__globals__.os.popen('id').read()}}\",\n            \"{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/hello.txt', 'w').write('Hello here !') }}\",\n            \"My name is {{ self.__init__.__globals__ }}\",\n            \"{{ dict.__base__.__subclasses__() }}\"\n        ]\n        for attempt in attempt_list:\n            with self.assertRaises(jinja2.exceptions.SecurityError):\n                safe_jinja.render(attempt)\n\n    def test_exception_debug_calls(self):\n        import jinja2\n        # Where sandbox should kick in - configs and debug calls\n        attempt_list = [\n            \"{% debug %}\",\n        ]\n        for attempt in attempt_list:\n            # Usually should be something like 'Encountered unknown tag 'debug'.'\n            with self.assertRaises(jinja2.exceptions.TemplateSyntaxError):\n                safe_jinja.render(attempt)\n\n    # https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection/jinja2-ssti#accessing-global-objects\n    def test_exception_empty_calls(self):\n        import jinja2\n        attempt_list = [\n            \"{{config}}\",\n            \"{{ debug }}\"\n            \"{{[].__class__}}\",\n        ]\n        for attempt in attempt_list:\n            self.assertEqual(len(safe_jinja.render(attempt)), 0, f\"string test '{attempt}' is correctly empty\")\n\n    def test_jinja2_escaped_html(self):\n        x = safe_jinja.render_fully_escaped('woo <a href=\"https://google.com\">dfdfd</a>')\n        self.assertEqual(x, \"woo &lt;a href=&#34;https://google.com&#34;&gt;dfdfd&lt;/a&gt;\")\n\n\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_notification_diff.py",
    "content": "#!/usr/bin/env python3\n\n# run from dir above changedetectionio/ dir\n# python3 -m unittest changedetectionio.tests.unit.test_notification_diff\n\nimport unittest\nimport os\n\nfrom changedetectionio import diff\nfrom changedetectionio.diff import (\n    REMOVED_PLACEMARKER_OPEN,\n    REMOVED_PLACEMARKER_CLOSED,\n    ADDED_PLACEMARKER_OPEN,\n    ADDED_PLACEMARKER_CLOSED,\n    CHANGED_PLACEMARKER_OPEN,\n    CHANGED_PLACEMARKER_CLOSED,\n    CHANGED_INTO_PLACEMARKER_OPEN,\n    CHANGED_INTO_PLACEMARKER_CLOSED\n)\n\n\n# mostly\nclass TestDiffBuilder(unittest.TestCase):\n\n    def test_expected_diff_output(self):\n        base_dir = os.path.dirname(__file__)\n        with open(base_dir + \"/test-content/before.txt\", 'r') as f:\n            previous_version_file_contents = f.read()\n\n        with open(base_dir + \"/test-content/after.txt\", 'r') as f:\n            newest_version_file_contents = f.read()\n\n        output = diff.render_diff(previous_version_file_contents=previous_version_file_contents,\n                                  newest_version_file_contents=newest_version_file_contents)\n\n        output = output.split(\"\\n\")\n\n        # Check that placemarkers are present (they get replaced in apply_service_tweaks)\n        self.assertTrue(any(CHANGED_PLACEMARKER_OPEN in line and 'ok' in line for line in output))\n        self.assertTrue(any(CHANGED_INTO_PLACEMARKER_OPEN in line and 'xok' in line for line in output))\n        self.assertTrue(any(CHANGED_INTO_PLACEMARKER_OPEN in line and 'next-x-ok' in line for line in output))\n        self.assertTrue(any(ADDED_PLACEMARKER_OPEN in line and 'and something new' in line for line in output))\n\n        with open(base_dir + \"/test-content/after-2.txt\", 'r') as f:\n            newest_version_file_contents = f.read()\n        output = diff.render_diff(previous_version_file_contents, newest_version_file_contents)\n        output = output.split(\"\\n\")\n        self.assertTrue(any(REMOVED_PLACEMARKER_OPEN in line and 'for having learned computerese,' in line for line in output))\n        self.assertTrue(any(REMOVED_PLACEMARKER_OPEN in line and 'I continue to examine bits, bytes and words' in line for line in output))\n\n        #diff_removed\n        with open(base_dir + \"/test-content/before.txt\", 'r') as f:\n            previous_version_file_contents = f.read()\n\n        with open(base_dir + \"/test-content/after.txt\", 'r') as f:\n            newest_version_file_contents = f.read()\n        output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False)\n        output = output.split(\"\\n\")\n        self.assertTrue(any(CHANGED_PLACEMARKER_OPEN in line and 'ok' in line for line in output))\n        self.assertTrue(any(CHANGED_INTO_PLACEMARKER_OPEN in line and 'xok' in line for line in output))\n        self.assertTrue(any(CHANGED_INTO_PLACEMARKER_OPEN in line and 'next-x-ok' in line for line in output))\n        self.assertFalse(any(ADDED_PLACEMARKER_OPEN in line and 'and something new' in line for line in output))\n\n        #diff_removed\n        with open(base_dir + \"/test-content/after-2.txt\", 'r') as f:\n            newest_version_file_contents = f.read()\n        output = diff.render_diff(previous_version_file_contents, newest_version_file_contents, include_equal=False, include_removed=True, include_added=False)\n        output = output.split(\"\\n\")\n        self.assertTrue(any(REMOVED_PLACEMARKER_OPEN in line and 'for having learned computerese,' in line for line in output))\n        self.assertTrue(any(REMOVED_PLACEMARKER_OPEN in line and 'I continue to examine bits, bytes and words' in line for line in output))\n\n    def test_expected_diff_patch_output(self):\n        base_dir = os.path.dirname(__file__)\n        with open(base_dir + \"/test-content/before.txt\", 'r') as f:\n            before = f.read()\n        with open(base_dir + \"/test-content/after.txt\", 'r') as f:\n            after = f.read()\n\n        output = diff.render_diff(previous_version_file_contents=before,\n                                  newest_version_file_contents=after,\n                                  patch_format=True)\n\n        \n\n        output = output.split(\"\\n\")\n\n        self.assertIn('-ok', output)\n        self.assertIn('+xok', output)\n        self.assertIn('+next-x-ok', output)\n        self.assertIn('+and something new', output)\n\n        # @todo test blocks of changed, blocks of added, blocks of removed\n\n    def test_word_level_diff(self):\n        \"\"\"Test word-level diff functionality\"\"\"\n        before = \"The quick brown fox jumps over the lazy dog\"\n        after = \"The fast brown cat jumps over the lazy dog\"\n\n        # Test with word_diff enabled\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True)\n        # Should highlight only changed words, not entire line\n        self.assertIn(f'{REMOVED_PLACEMARKER_OPEN}quick{REMOVED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{ADDED_PLACEMARKER_OPEN}fast{ADDED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{REMOVED_PLACEMARKER_OPEN}fox{REMOVED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{ADDED_PLACEMARKER_OPEN}cat{ADDED_PLACEMARKER_CLOSED}', output)\n        # Unchanged words should appear without markers\n        self.assertIn('brown', output)\n        self.assertIn('jumps', output)\n\n        # Test with word_diff disabled (line-level)\n        output = diff.render_diff(before, after, include_equal=False, word_diff=False)\n        # Should show full line changes\n        self.assertIn(f'{CHANGED_PLACEMARKER_OPEN}The quick brown fox jumps over the lazy dog{CHANGED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{CHANGED_INTO_PLACEMARKER_OPEN}The fast brown cat jumps over the lazy dog{CHANGED_INTO_PLACEMARKER_CLOSED}', output)\n\n    def test_word_level_diff_html(self):\n        \"\"\"Test word-level diff with HTML coloring\"\"\"\n        before = \"110 points by user\"\n        after = \"111 points by user\"\n\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True)\n        # Unchanged text should not be wrapped in spans\n        self.assertIn('points by user', output)\n        self.assertIn('11', output)  # Common prefix is unchanged\n\n        # The inner HTML uses REMOVED_INNER_STYLE and ADDED_INNER_STYLE for character-level highlighting\n        expected = f'{REMOVED_PLACEMARKER_OPEN}110{REMOVED_PLACEMARKER_CLOSED}{ADDED_PLACEMARKER_OPEN}111{ADDED_PLACEMARKER_CLOSED} points by user'\n        self.assertEqual(output, expected)\n\n\n\n    def test_context_lines(self):\n        \"\"\"Test context_lines parameter\"\"\"\n        before = \"\"\"Line 1\nLine 2\nLine 3\nOld line\nLine 5\nLine 6\nLine 7\nAnother old\nLine 9\nLine 10\"\"\"\n\n        after = \"\"\"Line 1\nLine 2\nLine 3\nNew line\nLine 5\nLine 6\nLine 7\nAnother new\nLine 9\nLine 10\"\"\"\n\n        # Test with no context\n        output = diff.render_diff(before, after, include_equal=False, context_lines=0, word_diff=True)\n        \n        lines = output.split(\"\\n\")\n        # Should only show changed lines\n        self.assertEqual(len([l for l in lines if l.strip()]), 2)  # Two changed lines\n        self.assertIn(f'{REMOVED_PLACEMARKER_OPEN}Old{REMOVED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{ADDED_PLACEMARKER_OPEN}New{ADDED_PLACEMARKER_CLOSED}', output)\n\n        # Test with 1 line of context\n        output = diff.render_diff(before, after, include_equal=False, context_lines=1, word_diff=True)\n        \n        lines = [l for l in output.split(\"\\n\") if l.strip()]\n        # Should show changed lines + 1 line before and after each\n        self.assertIn('Line 3', output)  # 1 line before first change\n        self.assertIn('Line 5', output)  # 1 line after first change\n        self.assertIn('Line 7', output)  # 1 line before second change\n        self.assertIn('Line 9', output)  # 1 line after second change\n        self.assertGreater(len(lines), 2)  # More than just the changed lines\n\n        # Test with 2 lines of context\n        output = diff.render_diff(before, after, include_equal=False, context_lines=2, word_diff=True)\n        \n        lines = [l for l in output.split(\"\\n\") if l.strip()]\n        # Should show changed lines + 2 lines before and after each\n        self.assertIn('Line 2', output)  # 2 lines before first change\n        self.assertIn('Line 6', output)  # 2 lines after first change\n        self.assertGreater(len(lines), 6)  # Even more context\n\n    def test_context_lines_with_include_equal(self):\n        \"\"\"Test that context_lines is ignored when include_equal=True\"\"\"\n        before = \"\"\"Line 1\nLine 2\nChanged line\nLine 4\"\"\"\n\n        after = \"\"\"Line 1\nLine 2\nModified line\nLine 4\"\"\"\n\n        # With include_equal=True, context_lines should be ignored\n        output_with_context = diff.render_diff(before, after, include_equal=True, context_lines=1)\n        output_without_context = diff.render_diff(before, after, include_equal=True, context_lines=0)\n\n        # Both should show all lines\n        self.assertIn('Line 1', output_with_context)\n        self.assertIn('Line 4', output_with_context)\n        self.assertIn('Line 1', output_without_context)\n        self.assertIn('Line 4', output_without_context)\n\n    def test_case_insensitive_comparison(self):\n        \"\"\"Test case-insensitive diff comparison\"\"\"\n        before = \"The Quick Brown Fox\"\n        after = \"The QUICK brown FOX\"\n\n        # With case-sensitive (default), should detect changes\n        output = diff.render_diff(before, after, include_equal=False, case_insensitive=False, word_diff=False)\n        self.assertIn(f'{CHANGED_PLACEMARKER_OPEN}The Quick Brown Fox{CHANGED_PLACEMARKER_CLOSED}', output)\n\n        # With case-insensitive, should detect no changes\n        output = diff.render_diff(before, after, include_equal=False, case_insensitive=True)\n        \n\n        # Should be empty or minimal since texts are equal when ignoring case\n        lines = [l for l in output.split(\"\\n\") if l.strip()]\n        self.assertEqual(len(lines), 0, \"Case-insensitive comparison should find no differences\")\n\n    def test_case_insensitive_with_real_changes(self):\n        \"\"\"Test case-insensitive comparison with actual content differences\"\"\"\n        before = \"Hello World\\nGoodbye WORLD to all my friends and family\"\n        after = \"HELLO world\\nGoodbye Friend to all my friends and family\"\n\n        # Case-insensitive should only detect the second line change\n        output = diff.render_diff(before, after, include_equal=False, case_insensitive=True, word_diff=True)\n        \n\n        # First line should not appear (same when ignoring case)\n        self.assertNotIn('Hello', output)\n        self.assertNotIn('HELLO', output)\n\n        # Second line should show the word change\n        self.assertIn(f'{REMOVED_PLACEMARKER_OPEN}WORLD{REMOVED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{ADDED_PLACEMARKER_OPEN}Friend{ADDED_PLACEMARKER_CLOSED}', output)\n\n    def test_case_insensitive_html_output(self):\n        \"\"\"Test case-insensitive comparison with HTML output\"\"\"\n        before = \"Price: $100\"\n        after = \"PRICE: $200\"\n\n        # Case-insensitive should only highlight the price change\n        output = diff.render_diff(before, after, include_equal=False, case_insensitive=True, word_diff=True)\n        \n\n        # Inner spans show the changes within the line\n        self.assertIn(CHANGED_PLACEMARKER_OPEN, output)\n        self.assertIn(CHANGED_INTO_PLACEMARKER_OPEN, output)\n        self.assertIn('00', output)  # Common suffix unchanged\n\n    def test_ignore_junk_word_diff_enabled(self):\n        \"\"\"Test ignore_junk with word_diff=True\"\"\"\n        before = \"The quick  brown   fox\"\n        after = \"The quick brown fox\"\n\n        # Without ignore_junk, should detect whitespace changes\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True, ignore_junk=False)\n        \n        # Should show some difference (whitespace changes)\n        self.assertTrue(len(output.strip()) > 0, \"Should detect whitespace changes when ignore_junk=False\")\n\n        # With ignore_junk, should ignore whitespace-only changes\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True, ignore_junk=True)\n        \n        lines = [l for l in output.split(\"\\n\") if l.strip()]\n        self.assertEqual(len(lines), 0, \"Should ignore whitespace-only changes when ignore_junk=True\")\n\n    def test_ignore_junk_word_diff_disabled(self):\n        \"\"\"Test ignore_junk with word_diff=False\"\"\"\n        before = \"Hello  World\"\n        after = \"Hello World\"\n\n        # Without ignore_junk, should detect line change\n        output = diff.render_diff(before, after, include_equal=False, word_diff=False, ignore_junk=False)\n        \n        self.assertIn(f'{CHANGED_PLACEMARKER_OPEN}Hello  World{CHANGED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{CHANGED_INTO_PLACEMARKER_OPEN}Hello World{CHANGED_INTO_PLACEMARKER_CLOSED}', output)\n\n        # With ignore_junk enabled and word_diff disabled\n        # When ignore_junk is enabled, whitespace is normalized at line level so lines match\n        output = diff.render_diff(before, after, include_equal=False, word_diff=False, ignore_junk=True)\n        \n        # Lines should be treated as equal\n        lines = [l for l in output.split(\"\\n\") if l.strip()]\n        self.assertEqual(len(lines), 0, \"Should ignore whitespace differences at line level\")\n\n    def test_ignore_junk_with_real_changes(self):\n        \"\"\"Test ignore_junk doesn't ignore actual word changes\"\"\"\n        before = \"The  quick   brown  fox\"\n        after = \"The quick brown cat\"\n\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True, ignore_junk=True)\n        \n        # Should still detect the word change (fox -> cat)\n        self.assertIn(f'{REMOVED_PLACEMARKER_OPEN}fox{REMOVED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{ADDED_PLACEMARKER_OPEN}cat{ADDED_PLACEMARKER_CLOSED}', output)\n        # But shouldn't highlight whitespace differences\n\n    def test_ignore_junk_tabs_vs_spaces(self):\n        \"\"\"Test ignore_junk treats tabs and spaces as equivalent\"\"\"\n        before = \"Column1\\tColumn2\\tColumn3\"\n        after = \"Column1    Column2    Column3\"\n\n        # Without ignore_junk, should detect difference\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True, ignore_junk=False)\n        \n        self.assertTrue(len(output.strip()) > 0, \"Should detect tab vs space differences\")\n\n        # With ignore_junk, should ignore tab/space differences\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True, ignore_junk=True)\n        \n        lines = [l for l in output.split(\"\\n\") if l.strip()]\n        self.assertEqual(len(lines), 0, \"Should ignore tab vs space differences when ignore_junk=True\")\n\n    def test_ignore_junk_html_output(self):\n        \"\"\"Test ignore_junk with placemarkers for value changes\"\"\"\n        before = \"Value:  100  points\"\n        after = \"Value: 200 points\"\n\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True, ignore_junk=True)\n        \n        # Should only highlight the actual value change\n        self.assertIn(f'{REMOVED_PLACEMARKER_OPEN}100{REMOVED_PLACEMARKER_CLOSED}', output)\n        self.assertIn(f'{ADDED_PLACEMARKER_OPEN}200{ADDED_PLACEMARKER_CLOSED}', output)\n        # Should not create separate spans for whitespace changes\n\n    def test_ignore_junk_case_insensitive_combination(self):\n        \"\"\"Test ignore_junk combined with case_insensitive\"\"\"\n        before = \"The  QUICK   Brown  Fox jumps over the lazy dog every day\"\n        after = \"The quick brown FOX jumps over the lazy dog every day\"\n\n        # Both enabled: should ignore case and whitespace\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True,\n                                 case_insensitive=True, ignore_junk=True)\n        \n        lines = [l for l in output.split(\"\\n\") if l.strip()]\n        self.assertEqual(len(lines), 0, \"Should ignore both case and whitespace differences\")\n\n        # Only case_insensitive: should detect whitespace changes\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True,\n                                 case_insensitive=True, ignore_junk=False)\n        \n        self.assertTrue(len(output.strip()) > 0, \"Should detect whitespace changes\")\n\n        # Only ignore_junk: should detect case changes\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True,\n                                 case_insensitive=False, ignore_junk=True)\n        \n        # Should detect case differences\n        self.assertIn('QUICK', output)\n        self.assertIn('quick', output)\n        self.assertIn('Brown', output)\n        self.assertIn('brown', output)\n        # Should show changes (though may be grouped together)\n        # Check that placemarkers appear in the output\n        self.assertTrue(REMOVED_PLACEMARKER_OPEN in output, \"Should show removed text\")\n        self.assertTrue(ADDED_PLACEMARKER_OPEN in output, \"Should show added text\")\n\n    def test_ignore_junk_multiline(self):\n        \"\"\"Test ignore_junk with multiple lines\"\"\"\n        before = \"\"\"Line 1  with  spaces\nLine 2 unchanged\nLine 3  with  tabs\tand  spaces\"\"\"\n\n        after = \"\"\"Line 1 with spaces\nLine 2 unchanged\nLine 3 with tabs and spaces\"\"\"\n\n        # With ignore_junk, should only show unchanged line when include_equal=True\n        output = diff.render_diff(before, after, include_equal=False, word_diff=True, ignore_junk=True)\n        \n        lines = [l for l in output.split(\"\\n\") if l.strip()]\n        # Should be empty since only whitespace changed\n        self.assertEqual(len(lines), 0, \"Should ignore whitespace changes across multiple lines\")\n\n        # Verify Line 2 is not shown as changed\n        self.assertNotIn('[-Line 2-]', output)\n        self.assertNotIn('[+Line 2+]', output)\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_restock_logic.py",
    "content": "#!/usr/bin/env python3\n\n# run from dir above changedetectionio/ dir\n# python3 -m unittest changedetectionio.tests.unit.test_restock_logic\n\nimport unittest\nimport os\n\nimport changedetectionio.processors.restock_diff.processor as restock_diff\n\n# mostly\nclass TestDiffBuilder(unittest.TestCase):\n\n    def test_logic(self):\n        assert restock_diff.is_between(number=10, lower=9, upper=11) == True, \"Between 9 and 11\"\n        assert restock_diff.is_between(number=10, lower=0, upper=11) == True, \"Between 9 and 11\"\n        assert restock_diff.is_between(number=10, lower=None, upper=11) == True, \"Between None and 11\"\n        assert not restock_diff.is_between(number=12, lower=None, upper=11) == True, \"12 is not between None and 11\"\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_scheduler.py",
    "content": "#!/usr/bin/env python3\n\n# run from dir above changedetectionio/ dir\n# python3 -m unittest changedetectionio.tests.unit.test_scheduler\n\nimport unittest\nimport arrow\n\nclass TestScheduler(unittest.TestCase):\n\n    # UTC+14:00 (Line Islands, Kiribati) is the farthest ahead, always ahead of UTC.\n    # UTC-12:00 (Baker Island, Howland Island) is the farthest behind, always one calendar day behind UTC.\n\n    def test_timezone_basic_time_within_schedule(self):\n        \"\"\"Test that current time is detected as within schedule window.\"\"\"\n        from changedetectionio import time_handler\n\n        timezone_str = 'Europe/Berlin'\n        debug_datetime = arrow.now(timezone_str)\n        day_of_week = debug_datetime.format('dddd')\n        time_str = debug_datetime.format('HH:00')\n        duration = 60  # minutes\n\n        # The current time should always be within 60 minutes of [time_hour]:00\n        result = time_handler.am_i_inside_time(day_of_week=day_of_week,\n                                               time_str=time_str,\n                                               timezone_str=timezone_str,\n                                               duration=duration)\n\n        self.assertEqual(result, True, f\"{debug_datetime} is within time scheduler {day_of_week} {time_str} in {timezone_str} for {duration} minutes\")\n\n    def test_timezone_basic_time_outside_schedule(self):\n        \"\"\"Test that time from yesterday is outside current schedule.\"\"\"\n        from changedetectionio import time_handler\n\n        timezone_str = 'Europe/Berlin'\n        # We try a date in the past (yesterday)\n        debug_datetime = arrow.now(timezone_str).shift(days=-1)\n        day_of_week = debug_datetime.format('dddd')\n        time_str = debug_datetime.format('HH:00')\n        duration = 60 * 24  # minutes\n\n        # The current time should NOT be within yesterday's schedule\n        result = time_handler.am_i_inside_time(day_of_week=day_of_week,\n                                               time_str=time_str,\n                                               timezone_str=timezone_str,\n                                               duration=duration)\n\n        self.assertNotEqual(result, True,\n                         f\"{debug_datetime} is NOT within time scheduler {day_of_week} {time_str} in {timezone_str} for {duration} minutes\")\n\n    def test_timezone_utc_within_schedule(self):\n        \"\"\"Test UTC timezone works correctly.\"\"\"\n        from changedetectionio import time_handler\n\n        timezone_str = 'UTC'\n        debug_datetime = arrow.now(timezone_str)\n        day_of_week = debug_datetime.format('dddd')\n        time_str = debug_datetime.format('HH:00')\n        duration = 120  # minutes\n\n        result = time_handler.am_i_inside_time(day_of_week=day_of_week,\n                                               time_str=time_str,\n                                               timezone_str=timezone_str,\n                                               duration=duration)\n\n        self.assertTrue(result, \"Current time should be within UTC schedule\")\n\n    def test_timezone_extreme_ahead(self):\n        \"\"\"Test with UTC+14 timezone (Line Islands, Kiribati).\"\"\"\n        from changedetectionio import time_handler\n\n        timezone_str = 'Pacific/Kiritimati'  # UTC+14\n        debug_datetime = arrow.now(timezone_str)\n        day_of_week = debug_datetime.format('dddd')\n        time_str = debug_datetime.format('HH:00')\n        duration = 60\n\n        result = time_handler.am_i_inside_time(day_of_week=day_of_week,\n                                               time_str=time_str,\n                                               timezone_str=timezone_str,\n                                               duration=duration)\n\n        self.assertTrue(result, \"Should work with extreme ahead timezone\")\n\n    def test_timezone_extreme_behind(self):\n        \"\"\"Test with UTC-12 timezone (Baker Island).\"\"\"\n        from changedetectionio import time_handler\n\n        # Using Etc/GMT+12 which is UTC-12 (confusing, but that's how it works)\n        timezone_str = 'Etc/GMT+12'  # UTC-12\n        debug_datetime = arrow.now(timezone_str)\n        day_of_week = debug_datetime.format('dddd')\n        time_str = debug_datetime.format('HH:00')\n        duration = 60\n\n        result = time_handler.am_i_inside_time(day_of_week=day_of_week,\n                                               time_str=time_str,\n                                               timezone_str=timezone_str,\n                                               duration=duration)\n\n        self.assertTrue(result, \"Should work with extreme behind timezone\")\n\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_semver.py",
    "content": "#!/usr/bin/env python3\n\n# run from dir above changedetectionio/ dir\n# python3 -m unittest changedetectionio.tests.unit.test_semver\n\nimport re\nimport unittest\n\n\n# The SEMVER regex\nSEMVER_REGEX = r\"^(?P<major>0|[1-9]\\d*)\\.(?P<minor>0|[1-9]\\d*)\\.(?P<patch>0|[1-9]\\d*)(?:-(?P<prerelease>(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$\"\n\n# Compile the regex\nsemver_pattern = re.compile(SEMVER_REGEX)\n\nclass TestSemver(unittest.TestCase):\n    def test_valid_versions(self):\n        \"\"\"Test valid semantic version strings\"\"\"\n        valid_versions = [\n            \"1.0.0\",\n            \"0.1.0\",\n            \"0.0.1\",\n            \"1.0.0-alpha\",\n            \"1.0.0-alpha.1\",\n            \"1.0.0-0.3.7\",\n            \"1.0.0-x.7.z.92\",\n            \"1.0.0-alpha+001\",\n            \"1.0.0+20130313144700\",\n            \"1.0.0-beta+exp.sha.5114f85\"\n        ]\n        for version in valid_versions:\n            with self.subTest(version=version):\n                self.assertIsNotNone(semver_pattern.match(version), f\"Version {version} should be valid\")\n\n    def test_invalid_versions(self):\n        \"\"\"Test invalid semantic version strings\"\"\"\n        invalid_versions = [\n            \"0.48.06\",\n            \"1.0\",\n            \"1.0.0-\",\n# Seems to pass the semver.org regex?\n#            \"1.0.0-alpha-\",\n            \"1.0.0+\",\n            \"1.0.0-alpha+\",\n            \"1.0.0-\",\n            \"01.0.0\",\n            \"1.01.0\",\n            \"1.0.01\",\n            \".1.0.0\",\n            \"1..0.0\"\n        ]\n        for version in invalid_versions:\n            with self.subTest(version=version):\n                res = semver_pattern.match(version)\n                self.assertIsNone(res, f\"Version '{version}' should be invalid\")\n\n    def test_our_version(self):\n        from changedetectionio import get_version\n        our_version = get_version()\n        self.assertIsNotNone(semver_pattern.match(our_version), f\"Our version '{our_version}' should be a valid SEMVER string\")\n\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_time_extension.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nSimple unit tests for TimeExtension that mimic how safe_jinja.py uses it.\nThese tests demonstrate that the environment.default_timezone override works\nexactly as intended in the actual application code.\n\"\"\"\n\nimport arrow\nfrom jinja2.sandbox import ImmutableSandboxedEnvironment\nfrom changedetectionio.jinja2_custom.extensions.TimeExtension import TimeExtension\n\n\ndef test_default_timezone_override_like_safe_jinja(mocker):\n    \"\"\"\n    Test that mirrors exactly how safe_jinja.py uses the TimeExtension.\n    This is the simplest demonstration that environment.default_timezone works.\n    \"\"\"\n    # Create environment (TimeExtension.__init__ sets default_timezone='UTC')\n    jinja2_env = ImmutableSandboxedEnvironment(extensions=[TimeExtension])\n\n    # Override the default timezone - exactly like safe_jinja.py does\n    jinja2_env.default_timezone = 'America/New_York'\n\n    # Mock arrow.now to return a fixed time\n    fixed_time = arrow.Arrow(2025, 1, 15, 12, 0, 0, tzinfo='America/New_York')\n    mock = mocker.patch(\"changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now\", return_value=fixed_time)\n\n    # Use empty string timezone - should use the overridden default\n    template_str = \"{% now '' %}\"\n    output = jinja2_env.from_string(template_str).render()\n\n    # Verify arrow.now was called with the overridden timezone\n    mock.assert_called_with('America/New_York')\n    assert '2025' in output\n    assert 'Jan' in output\n\n\ndef test_default_timezone_not_overridden(mocker):\n    \"\"\"\n    Test that without override, the default 'UTC' from __init__ is used.\n    \"\"\"\n    # Create environment (TimeExtension.__init__ sets default_timezone='UTC')\n    jinja2_env = ImmutableSandboxedEnvironment(extensions=[TimeExtension])\n\n    # DON'T override - should use 'UTC' default\n\n    # Mock arrow.now\n    fixed_time = arrow.Arrow(2025, 1, 15, 17, 0, 0, tzinfo='UTC')\n    mock = mocker.patch(\"changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now\", return_value=fixed_time)\n\n    # Use empty string timezone - should use 'UTC' default\n    template_str = \"{% now '' %}\"\n    output = jinja2_env.from_string(template_str).render()\n\n    # Verify arrow.now was called with 'UTC'\n    mock.assert_called_with('UTC')\n    assert '2025' in output\n\n\ndef test_datetime_format_override_like_safe_jinja(mocker):\n    \"\"\"\n    Test that environment.datetime_format can be overridden after creation.\n    \"\"\"\n    # Create environment (default format is '%a, %d %b %Y %H:%M:%S')\n    jinja2_env = ImmutableSandboxedEnvironment(extensions=[TimeExtension])\n\n    # Override the datetime format\n    jinja2_env.datetime_format = '%Y-%m-%d %H:%M:%S'\n\n    # Mock arrow.now\n    fixed_time = arrow.Arrow(2025, 1, 15, 14, 30, 45, tzinfo='UTC')\n    mocker.patch(\"changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now\", return_value=fixed_time)\n\n    # Don't specify format - should use overridden default\n    template_str = \"{% now 'UTC' %}\"\n    output = jinja2_env.from_string(template_str).render()\n\n    # Should use custom format YYYY-MM-DD HH:MM:SS\n    assert output == '2025-01-15 14:30:45'\n\n\ndef test_offset_with_overridden_timezone(mocker):\n    \"\"\"\n    Test that offset operations also respect the overridden default_timezone.\n    \"\"\"\n    jinja2_env = ImmutableSandboxedEnvironment(extensions=[TimeExtension])\n\n    # Override to use Europe/London\n    jinja2_env.default_timezone = 'Europe/London'\n\n    fixed_time = arrow.Arrow(2025, 1, 15, 10, 0, 0, tzinfo='Europe/London')\n    mock = mocker.patch(\"changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now\", return_value=fixed_time)\n\n    # Use offset with empty timezone string\n    template_str = \"{% now '' + 'hours=2', '%Y-%m-%d %H:%M:%S' %}\"\n    output = jinja2_env.from_string(template_str).render()\n\n    # Should have called arrow.now with Europe/London\n    mock.assert_called_with('Europe/London')\n    # Should be 10:00 + 2 hours = 12:00\n    assert output == '2025-01-15 12:00:00'\n\n\ndef test_weekday_parameter_converted_to_int(mocker):\n    \"\"\"\n    Test that weekday parameter is properly converted from float to int.\n    This is important because arrow.shift() requires weekday as int, not float.\n    \"\"\"\n    jinja2_env = ImmutableSandboxedEnvironment(extensions=[TimeExtension])\n\n    # Wednesday, Jan 15, 2025\n    fixed_time = arrow.Arrow(2025, 1, 15, 12, 0, 0, tzinfo='UTC')\n    mocker.patch(\"changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now\", return_value=fixed_time)\n\n    # Add offset to next Monday (weekday=0)\n    template_str = \"{% now 'UTC' + 'weekday=0', '%A' %}\"\n    output = jinja2_env.from_string(template_str).render()\n\n    # Should be Monday\n    assert output == 'Monday'\n\n\ndef test_multiple_offset_parameters(mocker):\n    \"\"\"\n    Test that multiple offset parameters can be combined in one expression.\n    \"\"\"\n    jinja2_env = ImmutableSandboxedEnvironment(extensions=[TimeExtension])\n\n    fixed_time = arrow.Arrow(2025, 1, 15, 10, 30, 45, tzinfo='UTC')\n    mocker.patch(\"changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now\", return_value=fixed_time)\n\n    # Test multiple parameters: days, hours, minutes, seconds\n    template_str = \"{% now 'UTC' + 'days=1,hours=2,minutes=15,seconds=10', '%Y-%m-%d %H:%M:%S' %}\"\n    output = jinja2_env.from_string(template_str).render()\n\n    # 2025-01-15 10:30:45 + 1 day + 2 hours + 15 minutes + 10 seconds\n    # = 2025-01-16 12:45:55\n    assert output == '2025-01-16 12:45:55'\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_time_handler.py",
    "content": "#!/usr/bin/env python3\n\n\"\"\"\nComprehensive tests for time_handler module refactored to use arrow.\n\nRun from project root:\npython3 -m pytest changedetectionio/tests/unit/test_time_handler.py -v\n\"\"\"\n\nimport unittest\nimport unittest.mock\nimport arrow\nfrom changedetectionio import time_handler\n\n\nclass TestAmIInsideTime(unittest.TestCase):\n    \"\"\"Tests for the am_i_inside_time function.\"\"\"\n\n    def test_current_time_within_schedule(self):\n        \"\"\"Test that current time is detected as within schedule.\"\"\"\n        # Get current time in a specific timezone\n        timezone_str = 'Europe/Berlin'\n        now = arrow.now(timezone_str)\n        day_of_week = now.format('dddd')\n        time_str = now.format('HH:00')  # Current hour, 0 minutes\n        duration = 60  # 60 minutes\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        self.assertTrue(result, f\"Current time should be within {duration} minute window starting at {time_str}\")\n\n    def test_current_time_outside_schedule(self):\n        \"\"\"Test that time in the past is not within current schedule.\"\"\"\n        timezone_str = 'Europe/Berlin'\n        # Get yesterday's date\n        yesterday = arrow.now(timezone_str).shift(days=-1)\n        day_of_week = yesterday.format('dddd')\n        time_str = yesterday.format('HH:mm')\n        duration = 30  # Only 30 minutes\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        self.assertFalse(result, \"Yesterday's time should not be within current schedule\")\n\n    def test_timezone_pacific_within_schedule(self):\n        \"\"\"Test with US/Pacific timezone.\"\"\"\n        timezone_str = 'US/Pacific'\n        now = arrow.now(timezone_str)\n        day_of_week = now.format('dddd')\n        time_str = now.format('HH:00')\n        duration = 120  # 2 hours\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        self.assertTrue(result)\n\n    def test_timezone_tokyo_within_schedule(self):\n        \"\"\"Test with Asia/Tokyo timezone.\"\"\"\n        timezone_str = 'Asia/Tokyo'\n        now = arrow.now(timezone_str)\n        day_of_week = now.format('dddd')\n        time_str = now.format('HH:00')\n        duration = 90  # 1.5 hours\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        self.assertTrue(result)\n\n    def test_schedule_crossing_midnight(self):\n        \"\"\"Test schedule that crosses midnight.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n\n        # Set schedule to start 23:30 with 120 minute duration (crosses midnight)\n        day_of_week = now.format('dddd')\n        time_str = \"23:30\"\n        duration = 120  # 2 hours - goes into next day\n\n        # If we're at 00:15 the next day, we should still be in the schedule\n        if now.hour == 0 and now.minute < 30:\n            # We're in the time window that spilled over from yesterday\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=time_str,\n                timezone_str=timezone_str,\n                duration=duration\n            )\n            # This might be true or false depending on exact time\n            self.assertIsInstance(result, bool)\n\n    def test_invalid_day_of_week(self):\n        \"\"\"Test that invalid day raises ValueError.\"\"\"\n        with self.assertRaises(ValueError) as context:\n            time_handler.am_i_inside_time(\n                day_of_week=\"Funday\",\n                time_str=\"12:00\",\n                timezone_str=\"UTC\",\n                duration=60\n            )\n        self.assertIn(\"Invalid day_of_week\", str(context.exception))\n\n    def test_invalid_time_format(self):\n        \"\"\"Test that invalid time format raises ValueError.\"\"\"\n        with self.assertRaises(ValueError) as context:\n            time_handler.am_i_inside_time(\n                day_of_week=\"Monday\",\n                time_str=\"25:99\",\n                timezone_str=\"UTC\",\n                duration=60\n            )\n        self.assertIn(\"Invalid time_str\", str(context.exception))\n\n    def test_invalid_time_format_non_numeric(self):\n        \"\"\"Test that non-numeric time raises ValueError.\"\"\"\n        with self.assertRaises(ValueError) as context:\n            time_handler.am_i_inside_time(\n                day_of_week=\"Monday\",\n                time_str=\"twelve:thirty\",\n                timezone_str=\"UTC\",\n                duration=60\n            )\n        self.assertIn(\"Invalid time_str\", str(context.exception))\n\n    def test_invalid_timezone(self):\n        \"\"\"Test that invalid timezone raises ValueError.\"\"\"\n        with self.assertRaises(ValueError) as context:\n            time_handler.am_i_inside_time(\n                day_of_week=\"Monday\",\n                time_str=\"12:00\",\n                timezone_str=\"Invalid/Timezone\",\n                duration=60\n            )\n        self.assertIn(\"Invalid timezone_str\", str(context.exception))\n\n    def test_short_duration(self):\n        \"\"\"Test with very short duration (15 minutes default).\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        day_of_week = now.format('dddd')\n        time_str = now.format('HH:mm')\n        duration = 15  # Default duration\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        self.assertTrue(result, \"Current time should be within 15 minute window\")\n\n    def test_long_duration(self):\n        \"\"\"Test with long duration (24 hours).\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        day_of_week = now.format('dddd')\n        # Set time to current hour\n        time_str = now.format('HH:00')\n        duration = 1440  # 24 hours in minutes\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        self.assertTrue(result, \"Current time should be within 24 hour window\")\n\n    def test_case_insensitive_day(self):\n        \"\"\"Test that day of week is case insensitive.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        day_of_week = now.format('dddd').lower()  # lowercase day\n        time_str = now.format('HH:00')\n        duration = 60\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        self.assertTrue(result, \"Lowercase day should work\")\n\n    def test_edge_case_midnight(self):\n        \"\"\"Test edge case at exactly midnight.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        day_of_week = now.format('dddd')\n        time_str = \"00:00\"\n        duration = 60\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        # Should be true if we're in the first hour of the day\n        if now.hour == 0:\n            self.assertTrue(result)\n\n    def test_edge_case_end_of_day(self):\n        \"\"\"Test edge case near end of day.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        day_of_week = now.format('dddd')\n        time_str = \"23:45\"\n        duration = 30  # 30 minutes crosses midnight\n\n        result = time_handler.am_i_inside_time(\n            day_of_week=day_of_week,\n            time_str=time_str,\n            timezone_str=timezone_str,\n            duration=duration\n        )\n\n        # Result depends on current time\n        self.assertIsInstance(result, bool)\n\n    def test_24_hour_schedule_from_midnight(self):\n        \"\"\"Test 24-hour schedule starting at midnight covers entire day.\"\"\"\n        timezone_str = 'UTC'\n        # Test at a specific time: Monday 00:00\n        test_time = arrow.get('2024-01-01 00:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')  # Monday\n\n        # Mock current time for testing\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"00:00\",\n                timezone_str=timezone_str,\n                duration=1440  # 24 hours\n            )\n            self.assertTrue(result, \"Should be active at start of 24-hour schedule\")\n\n    def test_24_hour_schedule_at_end_of_day(self):\n        \"\"\"Test 24-hour schedule is active at 23:59:59.\"\"\"\n        timezone_str = 'UTC'\n        # Test at Monday 23:59:59\n        test_time = arrow.get('2024-01-01 23:59:59', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')  # Monday\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"00:00\",\n                timezone_str=timezone_str,\n                duration=1440  # 24 hours\n            )\n            self.assertTrue(result, \"Should be active at end of 24-hour schedule\")\n\n    def test_24_hour_schedule_at_midnight_transition(self):\n        \"\"\"Test 24-hour schedule at exactly midnight transition.\"\"\"\n        timezone_str = 'UTC'\n        # Test at Tuesday 00:00:00 (end of Monday's 24-hour schedule)\n        test_time = arrow.get('2024-01-02 00:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        monday = test_time.shift(days=-1).format('dddd')  # Monday\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=monday,\n                time_str=\"00:00\",\n                timezone_str=timezone_str,\n                duration=1440  # 24 hours\n            )\n            self.assertTrue(result, \"Should include exactly midnight at end of 24-hour schedule\")\n\n    def test_schedule_crosses_midnight_before_midnight(self):\n        \"\"\"Test schedule crossing midnight - before midnight.\"\"\"\n        timezone_str = 'UTC'\n        # Monday 23:30\n        test_time = arrow.get('2024-01-01 23:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')  # Monday\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"23:00\",\n                timezone_str=timezone_str,\n                duration=120  # 2 hours (until 01:00 next day)\n            )\n            self.assertTrue(result, \"Should be active before midnight in cross-midnight schedule\")\n\n    def test_schedule_crosses_midnight_after_midnight(self):\n        \"\"\"Test schedule crossing midnight - after midnight.\"\"\"\n        timezone_str = 'UTC'\n        # Tuesday 00:30\n        test_time = arrow.get('2024-01-02 00:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        monday = test_time.shift(days=-1).format('dddd')  # Monday\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=monday,\n                time_str=\"23:00\",\n                timezone_str=timezone_str,\n                duration=120  # 2 hours (until 01:00 Tuesday)\n            )\n            self.assertTrue(result, \"Should be active after midnight in cross-midnight schedule\")\n\n    def test_schedule_crosses_midnight_at_exact_end(self):\n        \"\"\"Test schedule crossing midnight at exact end time.\"\"\"\n        timezone_str = 'UTC'\n        # Tuesday 01:00 (exact end of Monday 23:00 + 120 minutes)\n        test_time = arrow.get('2024-01-02 01:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        monday = test_time.shift(days=-1).format('dddd')  # Monday\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=monday,\n                time_str=\"23:00\",\n                timezone_str=timezone_str,\n                duration=120  # 2 hours\n            )\n            self.assertTrue(result, \"Should include exact end time of schedule\")\n\n    def test_duration_60_minutes(self):\n        \"\"\"Test that duration of 60 minutes works correctly.\"\"\"\n        timezone_str = 'UTC'\n        test_time = arrow.get('2024-01-01 12:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"12:00\",\n                timezone_str=timezone_str,\n                duration=60  # Exactly 60 minutes\n            )\n            self.assertTrue(result, \"60-minute duration should work\")\n\n    def test_duration_at_exact_end_minute(self):\n        \"\"\"Test at exact end of 60-minute window.\"\"\"\n        timezone_str = 'UTC'\n        # Exactly 13:00 (end of 12:00 + 60 minutes)\n        test_time = arrow.get('2024-01-01 13:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"12:00\",\n                timezone_str=timezone_str,\n                duration=60\n            )\n            self.assertTrue(result, \"Should include exact end minute\")\n\n    def test_one_second_after_schedule_ends(self):\n        \"\"\"Test one second after schedule should end.\"\"\"\n        timezone_str = 'UTC'\n        # 13:00:01 (one second after 12:00 + 60 minutes)\n        test_time = arrow.get('2024-01-01 13:00:01', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"12:00\",\n                timezone_str=timezone_str,\n                duration=60\n            )\n            self.assertFalse(result, \"Should be False one second after schedule ends\")\n\n    def test_multi_day_schedule(self):\n        \"\"\"Test schedule longer than 24 hours (48 hours).\"\"\"\n        timezone_str = 'UTC'\n        # Tuesday 12:00 (36 hours after Monday 00:00)\n        test_time = arrow.get('2024-01-02 12:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        monday = test_time.shift(days=-1).format('dddd')\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=monday,\n                time_str=\"00:00\",\n                timezone_str=timezone_str,\n                duration=2880  # 48 hours\n            )\n            self.assertTrue(result, \"Should support multi-day schedules\")\n\n    def test_schedule_one_minute_duration(self):\n        \"\"\"Test very short 1-minute schedule.\"\"\"\n        timezone_str = 'UTC'\n        test_time = arrow.get('2024-01-01 12:00:30', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"12:00\",\n                timezone_str=timezone_str,\n                duration=1  # Just 1 minute\n            )\n            self.assertTrue(result, \"1-minute schedule should work\")\n\n    def test_schedule_at_exact_start_time(self):\n        \"\"\"Test at exact start time (00:00:00.000000).\"\"\"\n        timezone_str = 'UTC'\n        test_time = arrow.get('2024-01-01 12:00:00.000000', 'YYYY-MM-DD HH:mm:ss.SSSSSS').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"12:00\",\n                timezone_str=timezone_str,\n                duration=30\n            )\n            self.assertTrue(result, \"Should include exact start time\")\n\n    def test_schedule_one_microsecond_before_start(self):\n        \"\"\"Test one microsecond before schedule starts.\"\"\"\n        timezone_str = 'UTC'\n        test_time = arrow.get('2024-01-01 11:59:59.999999', 'YYYY-MM-DD HH:mm:ss.SSSSSS').replace(tzinfo=timezone_str)\n        day_of_week = test_time.format('dddd')\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.am_i_inside_time(\n                day_of_week=day_of_week,\n                time_str=\"12:00\",\n                timezone_str=timezone_str,\n                duration=30\n            )\n            self.assertFalse(result, \"Should not include time before start\")\n\n\nclass TestIsWithinSchedule(unittest.TestCase):\n    \"\"\"Tests for the is_within_schedule function.\"\"\"\n\n    def test_schedule_disabled(self):\n        \"\"\"Test that disabled schedule returns False.\"\"\"\n        time_schedule_limit = {'enabled': False}\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertFalse(result)\n\n    def test_schedule_none(self):\n        \"\"\"Test that None schedule returns False.\"\"\"\n        result = time_handler.is_within_schedule(None)\n        self.assertFalse(result)\n\n    def test_schedule_empty_dict(self):\n        \"\"\"Test that empty dict returns False.\"\"\"\n        result = time_handler.is_within_schedule({})\n        self.assertFalse(result)\n\n    def test_schedule_enabled_but_day_disabled(self):\n        \"\"\"Test schedule enabled but current day disabled.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': False,\n                'start_time': '09:00',\n                'duration': {'hours': 8, 'minutes': 0}\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertFalse(result, \"Disabled day should return False\")\n\n    def test_schedule_enabled_within_time(self):\n        \"\"\"Test schedule enabled and within time window.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        current_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': current_hour,\n                'duration': {'hours': 2, 'minutes': 0}\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result, \"Current time should be within schedule\")\n\n    def test_schedule_enabled_outside_time(self):\n        \"\"\"Test schedule enabled but outside time window.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        # Set time to 3 hours ago\n        past_time = now.shift(hours=-3).format('HH:mm')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': past_time,\n                'duration': {'hours': 1, 'minutes': 0}  # Only 1 hour duration\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertFalse(result, \"3 hours ago with 1 hour duration should be False\")\n\n    def test_schedule_with_default_timezone(self):\n        \"\"\"Test schedule without timezone uses default.\"\"\"\n        now = arrow.now('America/New_York')\n        current_day = now.format('dddd').lower()\n        current_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            # No timezone specified\n            current_day: {\n                'enabled': True,\n                'start_time': current_hour,\n                'duration': {'hours': 2, 'minutes': 0}\n            }\n        }\n\n        # Should use default UTC, but since we're testing with NY time,\n        # the result depends on time difference\n        result = time_handler.is_within_schedule(\n            time_schedule_limit,\n            default_tz='America/New_York'\n        )\n        self.assertTrue(result, \"Should work with default timezone\")\n\n    def test_schedule_different_timezones(self):\n        \"\"\"Test schedule works correctly across different timezones.\"\"\"\n        # Test with Tokyo timezone\n        timezone_str = 'Asia/Tokyo'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        current_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': current_hour,\n                'duration': {'hours': 1, 'minutes': 30}\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result)\n\n    def test_schedule_with_minutes_in_duration(self):\n        \"\"\"Test schedule with minutes specified in duration.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        current_time = now.format('HH:mm')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': current_time,\n                'duration': {'hours': 0, 'minutes': 45}\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result, \"Should handle minutes in duration\")\n\n    def test_schedule_with_timezone_whitespace(self):\n        \"\"\"Test that timezone with whitespace is handled.\"\"\"\n        timezone_str = '  UTC  '\n        now = arrow.now('UTC')\n        current_day = now.format('dddd').lower()\n        current_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': current_hour,\n                'duration': {'hours': 1, 'minutes': 0}\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result, \"Should handle timezone with whitespace\")\n\n    def test_schedule_with_60_minutes(self):\n        \"\"\"Test schedule with duration of 0 hours and 60 minutes.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        current_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': current_hour,\n                'duration': {'hours': 0, 'minutes': 60}  # 60 minutes\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result, \"Should accept 60 minutes as valid duration\")\n\n    def test_schedule_with_24_hours(self):\n        \"\"\"Test schedule with duration of 24 hours and 0 minutes.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        start_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': start_hour,\n                'duration': {'hours': 24, 'minutes': 0}  # Full 24 hours\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result, \"Should accept 24 hours as valid duration\")\n\n    def test_schedule_with_90_minutes(self):\n        \"\"\"Test schedule with duration of 0 hours and 90 minutes.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        current_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': current_hour,\n                'duration': {'hours': 0, 'minutes': 90}  # 90 minutes = 1.5 hours\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result, \"Should accept 90 minutes as valid duration\")\n\n    def test_schedule_24_hours_from_midnight(self):\n        \"\"\"Test 24-hour schedule from midnight using is_within_schedule.\"\"\"\n        timezone_str = 'UTC'\n        test_time = arrow.get('2024-01-01 12:00:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        current_day = test_time.format('dddd').lower()  # monday\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': '00:00',\n                'duration': {'hours': 24, 'minutes': 0}\n            }\n        }\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.is_within_schedule(time_schedule_limit)\n            self.assertTrue(result, \"24-hour schedule from midnight should cover entire day\")\n\n    def test_schedule_24_hours_at_end_of_day(self):\n        \"\"\"Test 24-hour schedule at 23:59 using is_within_schedule.\"\"\"\n        timezone_str = 'UTC'\n        test_time = arrow.get('2024-01-01 23:59:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        current_day = test_time.format('dddd').lower()\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': '00:00',\n                'duration': {'hours': 24, 'minutes': 0}\n            }\n        }\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.is_within_schedule(time_schedule_limit)\n            self.assertTrue(result, \"Should be active at 23:59 in 24-hour schedule\")\n\n    def test_schedule_crosses_midnight_with_is_within_schedule(self):\n        \"\"\"Test schedule crossing midnight using is_within_schedule.\"\"\"\n        timezone_str = 'UTC'\n        # Tuesday 00:30\n        test_time = arrow.get('2024-01-02 00:30:00', 'YYYY-MM-DD HH:mm:ss').replace(tzinfo=timezone_str)\n        # Get Monday as that's when the schedule started\n        monday = test_time.shift(days=-1).format('dddd').lower()\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            'monday': {\n                'enabled': True,\n                'start_time': '23:00',\n                'duration': {'hours': 2, 'minutes': 0}  # Until 01:00 Tuesday\n            },\n            'tuesday': {\n                'enabled': False,\n                'start_time': '09:00',\n                'duration': {'hours': 8, 'minutes': 0}\n            }\n        }\n\n        with unittest.mock.patch('arrow.now', return_value=test_time):\n            result = time_handler.is_within_schedule(time_schedule_limit)\n            # Note: This checks Tuesday's schedule, not Monday's overlap\n            # So it should be False because Tuesday is disabled\n            self.assertFalse(result, \"Should check current day (Tuesday), which is disabled\")\n\n    def test_schedule_with_mixed_hours_minutes(self):\n        \"\"\"Test schedule with both hours and minutes (23 hours 60 minutes = 24 hours).\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        current_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': current_hour,\n                'duration': {'hours': 23, 'minutes': 60}  # = 1440 minutes = 24 hours\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result, \"Should handle 23 hours + 60 minutes = 24 hours\")\n\n    def test_schedule_48_hours(self):\n        \"\"\"Test schedule with 48-hour duration.\"\"\"\n        timezone_str = 'UTC'\n        now = arrow.now(timezone_str)\n        current_day = now.format('dddd').lower()\n        start_hour = now.format('HH:00')\n\n        time_schedule_limit = {\n            'enabled': True,\n            'timezone': timezone_str,\n            current_day: {\n                'enabled': True,\n                'start_time': start_hour,\n                'duration': {'hours': 48, 'minutes': 0}  # 2 full days\n            }\n        }\n\n        result = time_handler.is_within_schedule(time_schedule_limit)\n        self.assertTrue(result, \"Should support 48-hour (multi-day) schedules\")\n\n\nclass TestWeekdayEnum(unittest.TestCase):\n    \"\"\"Tests for the Weekday enum.\"\"\"\n\n    def test_weekday_values(self):\n        \"\"\"Test that weekday enum has correct values.\"\"\"\n        self.assertEqual(time_handler.Weekday.Monday, 0)\n        self.assertEqual(time_handler.Weekday.Tuesday, 1)\n        self.assertEqual(time_handler.Weekday.Wednesday, 2)\n        self.assertEqual(time_handler.Weekday.Thursday, 3)\n        self.assertEqual(time_handler.Weekday.Friday, 4)\n        self.assertEqual(time_handler.Weekday.Saturday, 5)\n        self.assertEqual(time_handler.Weekday.Sunday, 6)\n\n    def test_weekday_string_access(self):\n        \"\"\"Test accessing weekday enum by string.\"\"\"\n        self.assertEqual(time_handler.Weekday['Monday'], 0)\n        self.assertEqual(time_handler.Weekday['Sunday'], 6)\n\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/unit/test_watch_model.py",
    "content": "#!/usr/bin/env python3\n\n# run from dir above changedetectionio/ dir\n# python3 -m unittest changedetectionio.tests.unit.test_notification_diff\n\nimport unittest\nimport os\nimport pickle\nfrom copy import deepcopy\n\nfrom changedetectionio.model import Watch, Tag\n\n# mostly\nclass TestDiffBuilder(unittest.TestCase):\n\n    def test_watch_get_suggested_from_diff_timestamp(self):\n        import uuid as uuid_builder\n        # Create minimal mock datastore for tests\n        mock_datastore = {\n            'settings': {\n                'application': {}\n            },\n            'watching': {}\n        }\n        watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})\n        watch.ensure_data_dir_exists()\n\n\n        # Contents from the browser are always returned from the browser/requests/etc as str, str is basically UTF-16 in python\n        watch.save_history_blob(contents=\"hello world\", timestamp=100, snapshot_id=str(uuid_builder.uuid4()))\n        watch.save_history_blob(contents=\"hello world\", timestamp=105, snapshot_id=str(uuid_builder.uuid4()))\n        watch.save_history_blob(contents=\"hello world\", timestamp=109, snapshot_id=str(uuid_builder.uuid4()))\n        watch.save_history_blob(contents=\"hello world\", timestamp=112, snapshot_id=str(uuid_builder.uuid4()))\n        watch.save_history_blob(contents=\"hello world\", timestamp=115, snapshot_id=str(uuid_builder.uuid4()))\n        watch.save_history_blob(contents=\"hello world\", timestamp=117, snapshot_id=str(uuid_builder.uuid4()))\n    \n        p = watch.get_from_version_based_on_last_viewed\n        assert p == \"100\", \"Correct 'last viewed' timestamp was detected\"\n\n        watch['last_viewed'] = 110\n        p = watch.get_from_version_based_on_last_viewed\n        assert p == \"109\", \"Correct 'last viewed' timestamp was detected\"\n\n        watch['last_viewed'] = 116\n        p = watch.get_from_version_based_on_last_viewed\n        assert p == \"115\", \"Correct 'last viewed' timestamp was detected\"\n\n        watch['last_viewed'] = 99\n        p = watch.get_from_version_based_on_last_viewed\n        assert p == \"100\", \"When the 'last viewed' timestamp is less than the oldest snapshot, return oldest\"\n\n        watch['last_viewed'] = 200\n        p = watch.get_from_version_based_on_last_viewed\n        assert p == \"115\", \"When the 'last viewed' timestamp is greater than the newest snapshot, return second newest\"\n\n        watch['last_viewed'] = 109\n        p = watch.get_from_version_based_on_last_viewed\n        assert p == \"109\", \"Correct when its the same time\"\n\n        # new empty one\n        watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})\n        p = watch.get_from_version_based_on_last_viewed\n        assert p == None, \"None when no history available\"\n\n        watch.save_history_blob(contents=\"hello world\", timestamp=100, snapshot_id=str(uuid_builder.uuid4()))\n        p = watch.get_from_version_based_on_last_viewed\n        assert p == \"100\", \"Correct with only one history snapshot\"\n\n        watch['last_viewed'] = 200\n        p = watch.get_from_version_based_on_last_viewed\n        assert p == \"100\", \"Correct with only one history snapshot\"\n\n    def test_watch_deepcopy_doesnt_copy_datastore(self):\n        \"\"\"\n        CRITICAL: Ensure deepcopy(watch) shares __datastore instead of copying it.\n\n        Without this, deepcopy causes exponential memory growth:\n        - 100 watches × deepcopy each = 10,000 watch objects in memory (100²)\n        - Memory grows from 120MB → 2GB\n\n        This test prevents regressions in the __deepcopy__ implementation.\n        \"\"\"\n        # Create mock datastore with multiple watches\n        mock_datastore = {\n            'settings': {'application': {'history_snapshot_max_length': 10}},\n            'watching': {}\n        }\n\n        # Create 3 watches that all reference the same datastore\n        watches = []\n        for i in range(3):\n            watch = Watch.model(\n                __datastore=mock_datastore,\n                datastore_path='/tmp/test',\n                default={'url': f'https://example{i}.com', 'title': f'Watch {i}'}\n            )\n            mock_datastore['watching'][watch['uuid']] = watch\n            watches.append(watch)\n\n        # Test 1: Deepcopy shares datastore reference (doesn't copy it)\n        watch_copy = deepcopy(watches[0])\n\n        self.assertIsNotNone(watch_copy._datastore,\n                            \"__datastore should exist in copied watch\")\n        self.assertIs(watch_copy._datastore, watches[0]._datastore,\n                     \"__datastore should be SHARED (same object), not copied\")\n        self.assertIs(watch_copy._datastore, mock_datastore,\n                     \"__datastore should reference the original datastore\")\n\n        # Test 2: Dict data is properly copied (not shared)\n        self.assertEqual(watch_copy['title'], 'Watch 0', \"Dict data should be copied\")\n        watch_copy['title'] = 'MODIFIED'\n        self.assertNotEqual(watches[0]['title'], 'MODIFIED',\n                           \"Modifying copy should not affect original\")\n\n        # Test 3: Verify no nested datastore copies in watch dict\n        # The dict should only contain watch settings, not the datastore\n        watch_dict = dict(watch_copy)\n        self.assertNotIn('__datastore', watch_dict,\n                        \"__datastore should not be in dict keys\")\n        self.assertNotIn('_model__datastore', watch_dict,\n                        \"_model__datastore should not be in dict keys\")\n\n        # Test 4: Multiple deepcopies don't cause exponential memory growth\n        # If datastore was copied, each copy would contain 3 watches,\n        # and those watches would contain the datastore, etc. (infinite recursion)\n        copies = []\n        for _ in range(5):\n            copies.append(deepcopy(watches[0]))\n\n        # All copies should share the same datastore\n        for copy in copies:\n            self.assertIs(copy._datastore, mock_datastore,\n                         \"All copies should share the original datastore\")\n\n    def test_watch_pickle_doesnt_serialize_datastore(self):\n        \"\"\"\n        Ensure pickle/unpickle doesn't serialize __datastore.\n\n        This is important for multiprocessing and caching - we don't want\n        to serialize the entire datastore when pickling a watch.\n        \"\"\"\n        mock_datastore = {\n            'settings': {'application': {}},\n            'watching': {}\n        }\n\n        watch = Watch.model(\n            __datastore=mock_datastore,\n            datastore_path='/tmp/test',\n            default={'url': 'https://example.com', 'title': 'Test Watch'}\n        )\n\n        # Pickle and unpickle\n        pickled = pickle.dumps(watch)\n        unpickled_watch = pickle.loads(pickled)\n\n        # Test 1: Watch data is preserved\n        self.assertEqual(unpickled_watch['url'], 'https://example.com',\n                        \"Dict data should be preserved after pickle/unpickle\")\n\n        # Test 2: __datastore is NOT serialized (attribute shouldn't exist after unpickle)\n        self.assertFalse(hasattr(unpickled_watch, '_datastore'),\n                         \"__datastore attribute should not exist after unpickle (not serialized)\")\n\n        # Test 3: Pickled data shouldn't contain the large datastore object\n        # If datastore was serialized, the pickle size would be much larger\n        pickle_size = len(pickled)\n        # A single watch should be small (< 10KB), not include entire datastore\n        self.assertLess(pickle_size, 10000,\n                       f\"Pickled watch too large ({pickle_size} bytes) - might include datastore\")\n\n    def test_tag_deepcopy_works(self):\n        \"\"\"\n        Ensure Tag objects (which also inherit from watch_base) can be deepcopied.\n\n        Tags now have optional __datastore for consistency with Watch objects.\n        \"\"\"\n        mock_datastore = {\n            'settings': {'application': {}},\n            'watching': {}\n        }\n\n        # Test 1: Tag without datastore (backward compatibility)\n        tag_without_ds = Tag.model(\n            datastore_path='/tmp/test',\n            default={'title': 'Test Tag', 'overrides_watch': True}\n        )\n        tag_copy1 = deepcopy(tag_without_ds)\n        self.assertEqual(tag_copy1['title'], 'Test Tag', \"Tag data should be copied\")\n\n        # Test 2: Tag with datastore (new pattern for consistency)\n        tag_with_ds = Tag.model(\n            datastore_path='/tmp/test',\n            __datastore=mock_datastore,\n            default={'title': 'Test Tag With DS', 'overrides_watch': True}\n        )\n\n        # Deepcopy should work\n        tag_copy2 = deepcopy(tag_with_ds)\n\n        # Test 3: Dict data is copied\n        self.assertEqual(tag_copy2['title'], 'Test Tag With DS', \"Tag data should be copied\")\n\n        # Test 4: Modifications to copy don't affect original\n        tag_copy2['title'] = 'MODIFIED'\n        self.assertNotEqual(tag_with_ds['title'], 'MODIFIED',\n                           \"Modifying copy should not affect original\")\n\n        # Test 5: Tag with datastore shares it (doesn't copy it)\n        if hasattr(tag_with_ds, '_datastore'):\n            self.assertIs(tag_copy2._datastore, tag_with_ds._datastore,\n                         \"Tag should share __datastore reference like Watch does\")\n\n    def test_watch_copy_performance(self):\n        \"\"\"\n        Verify that our __deepcopy__ implementation doesn't cause performance issues.\n\n        With the fix, deepcopy should be fast because we're sharing datastore\n        instead of copying it.\n        \"\"\"\n        import time\n\n        # Create a watch with large datastore (many watches)\n        mock_datastore = {\n            'settings': {'application': {}},\n            'watching': {}\n        }\n\n        # Add 100 watches to the datastore\n        for i in range(100):\n            w = Watch.model(\n                __datastore=mock_datastore,\n                datastore_path='/tmp/test',\n                default={'url': f'https://example{i}.com'}\n            )\n            mock_datastore['watching'][w['uuid']] = w\n\n        # Time how long deepcopy takes\n        watch = list(mock_datastore['watching'].values())[0]\n\n        start = time.time()\n        for _ in range(10):\n            _ = deepcopy(watch)\n        elapsed = time.time() - start\n\n        # Should be fast (< 0.1 seconds for 10 copies)\n        # If datastore was copied, it would take much longer\n        self.assertLess(elapsed, 0.5,\n                       f\"Deepcopy too slow ({elapsed:.3f}s for 10 copies) - might be copying datastore\")\n\nif __name__ == '__main__':\n    unittest.main()\n"
  },
  {
    "path": "changedetectionio/tests/util.py",
    "content": "#!/usr/bin/env python3\nfrom operator import truediv\n\nfrom flask import make_response, request, current_app\nfrom flask import url_for\nimport logging\nimport time\nimport os\nimport threading\n\n# Thread-safe global storage for test endpoint content\n# Avoids filesystem cache issues in parallel tests\n_test_endpoint_content_lock = threading.Lock()\n_test_endpoint_content = {}\n\ndef write_test_file_and_sync(filepath, content, mode='w'):\n    \"\"\"\n    Write test data to file and ensure it's synced to disk.\n    Also stores in thread-safe global dict to bypass filesystem cache.\n\n    Critical for parallel tests where workers may read files immediately after write.\n    Without fsync(), data may still be in OS buffers when workers try to read,\n    causing race conditions where old data is seen.\n\n    Args:\n        filepath: Full path to file\n        content: Content to write (str or bytes)\n        mode: File mode ('w' for text, 'wb' for binary)\n    \"\"\"\n    # Convert content to bytes if needed\n    if isinstance(content, str):\n        content_bytes = content.encode('utf-8')\n    else:\n        content_bytes = content\n\n    # Store in thread-safe global dict for instant access\n    with _test_endpoint_content_lock:\n        _test_endpoint_content[os.path.basename(filepath)] = content_bytes\n\n    # Also write to file for compatibility\n    with open(filepath, mode) as f:\n        f.write(content)\n        f.flush()  # Flush Python buffer to OS\n        os.fsync(f.fileno())  # Force OS to write to disk\n\ndef set_original_response(datastore_path, extra_title=''):\n    test_return_data = f\"\"\"<html>\n    <head><title>head title{extra_title}</title></head>\n    <body>\n     Some initial text<br>\n     <p>Which is across multiple lines</p>\n     <br>\n     So let's see what happens.  <br>\n     <span class=\"foobar-detection\" style='display:none'></span>\n     </body>\n     </html>\n    \"\"\"\n\n    write_test_file_and_sync(os.path.join(datastore_path, \"endpoint-content.txt\"), test_return_data)\n    return None\n\ndef set_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n    <head><title>modified head title</title></head>\n    <body>\n     Some initial text<br>\n     <p>which has this one new line</p>\n     <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    write_test_file_and_sync(os.path.join(datastore_path, \"endpoint-content.txt\"), test_return_data)\n    return None\ndef set_longer_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n    <head><title>modified head title</title></head>\n    <body>\n     Some initial text<br>\n     <p>which has this one new line</p>\n     <br>\n     So let's see what happens.  <br>\n     So let's see what happens.  <br>\n      So let's see what happens.  <br>\n     So let's see what happens.  <br>\n     </body>\n     </html>\n    \"\"\"\n\n    write_test_file_and_sync(os.path.join(datastore_path, \"endpoint-content.txt\"), test_return_data)\n    return None\n\ndef set_more_modified_response(datastore_path):\n    test_return_data = \"\"\"<html>\n    <head><title>modified head title</title></head>\n    <body>\n     Some initial text<br>\n     <p>which has this one new line</p>\n     <br>\n     So let's see what happens.  <br>\n     Ohh yeah awesome<br>\n     </body>\n     </html>\n    \"\"\"\n\n    write_test_file_and_sync(os.path.join(datastore_path, \"endpoint-content.txt\"), test_return_data)\n    return None\n\n\ndef set_empty_text_response(datastore_path):\n    test_return_data = \"\"\"<html><body></body></html>\"\"\"\n\n    write_test_file_and_sync(os.path.join(datastore_path, \"endpoint-content.txt\"), test_return_data)\n\n    return None\n\ndef wait_for_notification_endpoint_output(datastore_path):\n    '''Apprise can take a few seconds to fire'''\n    #@todo - could check the apprise object directly instead of looking for this file\n    from os.path import isfile\n    notification_file = os.path.join(datastore_path, \"notification.txt\")\n    for i in range(1, 20):\n        time.sleep(1)\n        if isfile(notification_file):\n            return True\n\n    return False\n\n# kinda funky, but works for now\ndef get_UUID_for_tag_name(client, name):\n    app_config = client.application.config.get('DATASTORE').data\n    for uuid, tag in app_config['settings']['application'].get('tags', {}).items():\n        if name == tag.get('title', '').lower().strip():\n            return uuid\n    return None\n\n\n# kinda funky, but works for now\ndef extract_rss_token_from_UI(client):\n    return client.application.config.get('DATASTORE').data['settings']['application'].get('rss_access_token')\n#    import re\n#    res = client.get(\n#        url_for(\"watchlist.index\"),\n#    )\n#    m = re.search('token=(.+?)\"', str(res.data))\n#    token_key = m.group(1)\n#    return token_key.strip()\n\n# kinda funky, but works for now\ndef extract_UUID_from_client(client):\n    import re\n    res = client.get(\n        url_for(\"watchlist.index\"),\n    )\n    # <span id=\"api-key\">{{api_key}}</span>\n\n    m = re.search('edit/(.+?)[#\"]', str(res.data))\n    uuid = m.group(1)\n    return uuid.strip()\n\ndef delete_all_watches(client=None):\n    wait_for_all_checks(client)\n\n    uuids = list(client.application.config.get('DATASTORE').data['watching'])\n    for uuid in uuids:\n        client.application.config.get('DATASTORE').delete(uuid)\n    from changedetectionio.flask_app import update_q\n\n    # Clear the queue to prevent leakage to next test\n    # Use clear() method to ensure both priority_items and notification_queue are drained\n    if hasattr(update_q, 'clear'):\n        update_q.clear()\n    else:\n        # Fallback for old implementation\n        while not update_q.empty():\n            try:\n                update_q.get_nowait()\n            except:\n                break\n\n    time.sleep(0.2)\n\n    # Delete any old watch metadata\n    from pathlib import Path\n\n    base_path = Path(\n        client.application.config.get('DATASTORE').datastore_path\n    ).resolve()\n\n    max_depth = 2\n\n    for file in base_path.rglob(\"*.json\"):\n        # Calculate depth relative to base path\n        depth = len(file.relative_to(base_path).parts) - 1\n\n        if depth <= max_depth and file.is_file():\n            file.unlink()\n\n\ndef wait_for_all_checks(client=None):\n    \"\"\"\n    Waits until the queue is empty and workers are idle.\n    Delegates to worker_pool.wait_for_all_checks for shared logic.\n    \"\"\"\n    from changedetectionio.flask_app import update_q as global_update_q\n    from changedetectionio import worker_pool\n    return worker_pool.wait_for_all_checks(global_update_q, timeout=150)\n\n\ndef wait_for_watch_history(client, min_history_count=2, timeout=10):\n    \"\"\"\n    Wait for watches to have sufficient history entries.\n    Useful after wait_for_all_checks() when you need to ensure history is populated.\n\n    Args:\n        client: Test client with access to datastore\n        min_history_count: Minimum number of history entries required\n        timeout: Maximum time to wait in seconds\n    \"\"\"\n    datastore = client.application.config.get('DATASTORE')\n    start_time = time.time()\n\n    while time.time() - start_time < timeout:\n        all_have_history = True\n        for uuid, watch in datastore.data['watching'].items():\n            history_count = len(watch.history.keys())\n            if history_count < min_history_count:\n                all_have_history = False\n                break\n\n        if all_have_history:\n            return True\n\n        time.sleep(0.2)\n\n    # Timeout - return False\n    return False\n\n\n# Replaced by new_live_server_setup and calling per function scope in conftest.py\ndef  live_server_setup(live_server):\n    return True\n\ndef new_live_server_setup(live_server):\n\n    @live_server.app.route('/test-random-content-endpoint')\n    def test_random_content_endpoint():\n        import secrets\n        return \"Random content - {}\\n\".format(secrets.token_hex(64))\n\n    @live_server.app.route('/test-endpoint2')\n    def test_endpoint2():\n        return \"<html><body>some basic content</body></html>\"\n\n    @live_server.app.route('/test-endpoint')\n    def test_endpoint():\n        # REMOVED: logger.debug() causes file locking between test process and Flask server process\n        # Flask server runs in separate multiprocessing.Process and inherited loguru tries to\n        # write to same log files, causing request handlers to block on file locks\n        # from loguru import logger\n        # logger.debug(f\"/test-endpoint hit {request}\")\n        ctype = request.args.get('content_type')\n        status_code = request.args.get('status_code')\n        content = request.args.get('content') or None\n        delay = int(request.args.get('delay', 0))\n\n        if delay:\n            time.sleep(delay)\n\n        # Used to just try to break the header detection\n        uppercase_headers = request.args.get('uppercase_headers')\n\n        try:\n            if content is not None:\n                resp = make_response(content, status_code)\n                if uppercase_headers:\n                    ctype=ctype.upper()\n                    resp.headers['CONTENT-TYPE'] = ctype if ctype else 'text/html'\n                else:\n                    resp.headers['Content-Type'] = ctype if ctype else 'text/html'\n                return resp\n\n            # Check thread-safe global dict first (instant, no cache issues)\n            # Fall back to file if not in dict (for tests that write directly)\n            with _test_endpoint_content_lock:\n                content_data = _test_endpoint_content.get(\"endpoint-content.txt\")\n\n            if content_data is None:\n                # Not in global dict, read from file\n                datastore_path = current_app.config.get('TEST_DATASTORE_PATH', 'test-datastore')\n                filepath = os.path.join(datastore_path, \"endpoint-content.txt\")\n\n                # REMOVED: os.sync() was blocking for many seconds during parallel tests\n                # With -n 6+ parallel tests, heavy I/O causes os.sync() to wait for ALL\n                # system writes to complete, causing \"Read timed out\" errors\n                # File writes from test code are already flushed by the time workers fetch\n\n                try:\n                    with open(filepath, \"rb\") as f:\n                        content_data = f.read()\n                except Exception as e:\n                    # REMOVED: logger.error() causes file locking in multiprocess context\n                    # Just raise the exception directly for debugging\n                    raise\n\n            resp = make_response(content_data, status_code)\n            if uppercase_headers:\n                resp.headers['CONTENT-TYPE'] = ctype if ctype else 'text/html'\n            else:\n                resp.headers['Content-Type'] = ctype if ctype else 'text/html'\n            return resp\n        except FileNotFoundError:\n            return make_response('', status_code)\n\n    # Just return the headers in the request\n    @live_server.app.route('/test-headers')\n    def test_headers():\n\n        output = []\n\n        for header in request.headers:\n            output.append(\"{}:{}\".format(str(header[0]), str(header[1])))\n\n        content = \"\\n\".join(output)\n\n        resp = make_response(content, 200)\n        resp.headers['server'] = 'custom'\n        return resp\n\n    # Just return the body in the request\n    @live_server.app.route('/test-body', methods=['POST', 'GET'])\n    def test_body():\n        print (\"TEST-BODY GOT\", request.data, \"returning\")\n        return request.data\n\n    # Just return the verb in the request\n    @live_server.app.route('/test-method', methods=['POST', 'GET', 'PATCH'])\n    def test_method():\n        return request.method\n\n    # Where we POST to as a notification, also use a space here to test URL escaping is OK across all tests that use this. ( #2868 )\n    @live_server.app.route('/test_notification_endpoint', methods=['POST', 'GET'])\n    def test_notification_endpoint():\n        datastore_path = current_app.config.get('TEST_DATASTORE_PATH', 'test-datastore')\n\n        with open(os.path.join(datastore_path, \"notification.txt\"), \"wb\") as f:\n            # Debug method, dump all POST to file also, used to prove #65\n            data = request.stream.read()\n            if data != None:\n                f.write(data)\n\n        with open(os.path.join(datastore_path, \"notification-url.txt\"), \"w\") as f:\n            f.write(request.url)\n\n        with open(os.path.join(datastore_path, \"notification-headers.txt\"), \"w\") as f:\n            f.write(str(request.headers))\n\n        if request.content_type:\n            with open(os.path.join(datastore_path, \"notification-content-type.txt\"), \"w\") as f:\n                f.write(request.content_type)\n\n        print(\"\\n>> Test notification endpoint was hit.\\n\", data)\n\n        content = \"Text was set\"\n        status_code = request.args.get('status_code',200)\n        resp = make_response(content, status_code)\n        return resp\n\n    # Just return the verb in the request\n    @live_server.app.route('/test-basicauth', methods=['GET'])\n    def test_basicauth_method():\n        auth = request.authorization\n        ret = \" \".join([auth.username, auth.password, auth.type])\n        return ret\n\n    # Just return some GET var\n    @live_server.app.route('/test-return-query', methods=['GET'])\n    def test_return_query():\n        return request.query_string\n\n\n    @live_server.app.route('/endpoint-test.pdf')\n    def test_pdf_endpoint():\n        datastore_path = current_app.config.get('TEST_DATASTORE_PATH', 'test-datastore')\n\n        # Force filesystem sync before reading to ensure fresh data\n        try:\n            os.sync()\n        except (AttributeError, PermissionError):\n            pass\n\n        # Tried using a global var here but didn't seem to work, so reading from a file instead.\n        with open(os.path.join(datastore_path, \"endpoint-test.pdf\"), \"rb\") as f:\n            resp = make_response(f.read(), 200)\n            resp.headers['Content-Type'] = 'application/pdf'\n            return resp\n\n    @live_server.app.route('/test-interactive-html-endpoint')\n    def test_interactive_html_endpoint():\n        header_text=\"\"\n        for k,v in request.headers.items():\n            header_text += f\"{k}: {v}<br>\"\n\n        resp = make_response(f\"\"\"\n        <html>\n          <body>\n          Primitive JS check for <pre>changedetectionio/tests/visualselector/test_fetch_data.py</pre>\n            <p id=\"remove\">This text should be removed</p>\n              <form onsubmit=\"event.preventDefault();\">\n            <!-- obfuscated text so that we dont accidentally get a false positive due to conversion of the source :) --->\n                <button name=\"test-button\" onclick=\"\n                getElementById('remove').remove();\n                getElementById('some-content').innerHTML = atob('SSBzbWVsbCBKYXZhU2NyaXB0IGJlY2F1c2UgdGhlIGJ1dHRvbiB3YXMgcHJlc3NlZCE=');\n                getElementById('reflect-text').innerHTML = getElementById('test-input-text').value;\n                \">Click here</button>\n                \n                <div id=\"some-content\"></div>\n                \n                <pre>\n                {header_text.lower()}\n                </pre>\n                \n                <br>\n                <!-- used for testing that the jinja2 compiled here --->\n                <input type=\"text\" value=\"\" id=\"test-input-text\" /><br>\n                <div id=\"reflect-text\">Waiting to reflect text from #test-input-text here</div>\n              </form>\n                \n           </body>\n         </html>\"\"\", 200)\n        resp.headers['Content-Type'] = 'text/html'\n        return resp\n\n    live_server.start()\n"
  },
  {
    "path": "changedetectionio/tests/visualselector/__init__.py",
    "content": "\"\"\"Tests for the app.\"\"\"\n\n"
  },
  {
    "path": "changedetectionio/tests/visualselector/conftest.py",
    "content": "#!/usr/bin/env python3\n\nfrom .. import conftest\n"
  },
  {
    "path": "changedetectionio/tests/visualselector/test_fetch_data.py",
    "content": "#!/usr/bin/env python3\n\nimport os\nfrom flask import url_for\nfrom ..util import live_server_setup, wait_for_all_checks\n\n# def test_setup(client, live_server, measure_memory_usage, datastore_path):\n   #  live_server_setup(live_server) # Setup on conftest per function\n\n\n# Add a site in paused mode, add an invalid filter, we should still have visual selector data ready\ndef test_visual_selector_content_ready(client, live_server, measure_memory_usage, datastore_path):\n\n    import os\n    import json\n\n    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), \"Needs PLAYWRIGHT_DRIVER_URL set for this test\"\n\n    # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url\n    test_url = url_for('test_interactive_html_endpoint', _external=True)\n    test_url = test_url.replace('localhost.localdomain', 'cdio')\n    test_url = test_url.replace('localhost', 'cdio')\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid, unpause_on_save=1),\n        data={\n            \"url\": test_url,\n            \"tags\": \"\",\n            # For now, cookies doesnt work in headers because it must be a full cookiejar object\n            'headers': \"testheader: yes\\buser-agent: MyCustomAgent\",\n            'fetch_backend': \"html_webdriver\",\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n    wait_for_all_checks(client)\n\n\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, \"Watch history had atleast 1 (everything fetched OK)\"\n\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuid),\n        follow_redirects=True\n    )\n    assert b\"testheader: yes\" in res.data\n    assert b\"user-agent: mycustomagent\" in res.data\n\n\n    assert os.path.isfile(os.path.join(datastore_path, uuid, 'last-screenshot.png')), \"last-screenshot.png should exist\"\n    assert os.path.isfile(os.path.join(datastore_path, uuid, 'elements.deflate')), \"xpath elements.deflate data should exist\"\n\n    # Open it and see if it roughly looks correct\n    with open(os.path.join(datastore_path, uuid, 'elements.deflate'), 'rb') as f:\n        import zlib\n        compressed_data = f.read()\n        decompressed_data = zlib.decompress(compressed_data)\n        # See if any error was thrown\n        json_data = json.loads(decompressed_data.decode('utf-8'))\n\n    # Attempt to fetch it via the web hook that the browser would use\n    res = client.get(url_for('static_content', group='visual_selector_data', filename=uuid))\n    decompressed_data = zlib.decompress(res.data)\n    json_data = json.loads(decompressed_data.decode('utf-8'))\n    \n    assert res.mimetype == 'application/json'\n    assert res.status_code == 200\n\n\n    # Some options should be enabled\n    # @todo - in the future, the visibility should be toggled by JS from the request type setting\n    res = client.get(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\"),\n        follow_redirects=True\n    )\n    assert b'notification_screenshot' in res.data\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n\ndef test_basic_browserstep(client, live_server, measure_memory_usage, datastore_path):\n\n\n    test_url = url_for('test_interactive_html_endpoint', _external=True)\n    test_url = test_url.replace('localhost.localdomain', 'cdio')\n    test_url = test_url.replace('localhost', 'cdio')\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": test_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=\"first\", unpause_on_save=1),\n        data={\n            \"url\": test_url,\n            \"tags\": \"\",\n            'fetch_backend': \"html_webdriver\",\n            'browser_steps-5-operation': 'Enter text in field',\n            'browser_steps-5-selector': '#test-input-text',\n            # Should get set to the actual text (jinja2 rendered)\n            'browser_steps-5-optional_value': \"Hello-Jinja2-{% now  'Europe/Berlin', '%Y-%m-%d' %}\",\n            'browser_steps-8-operation': 'Click element',\n            'browser_steps-8-selector': 'button[name=test-button]',\n            'browser_steps-8-optional_value': '',\n            # For now, cookies doesnt work in headers because it must be a full cookiejar object\n            'headers': \"testheader: yes\\buser-agent: MyCustomAgent\",\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n\n    wait_for_all_checks(client)\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    # 3874 - should have tidied up any blanks\n    watch = live_server.app.config['DATASTORE'].data['watching'][uuid]\n    assert watch['browser_steps'][0].get('operation') == 'Enter text in field'\n    assert watch['browser_steps'][1].get('selector') == 'button[name=test-button]'\n\n\n    # This part actually needs the browser, before this we are just testing data\n    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), \"Needs PLAYWRIGHT_DRIVER_URL set for this test\"\n    assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, \"Watch history had atleast 1 (everything fetched OK)\"\n\n    assert b\"This text should be removed\" not in res.data\n\n    # Check HTML conversion detected and workd\n    res = client.get(\n        url_for(\"ui.ui_preview.preview_page\", uuid=uuid),\n        follow_redirects=True\n    )\n    assert b\"This text should be removed\" not in res.data\n    assert b\"I smell JavaScript because the button was pressed\" in res.data\n\n    assert b'Hello-Jinja2-20' in res.data\n\n    assert b\"testheader: yes\" in res.data\n    assert b\"user-agent: mycustomagent\" in res.data\n\ndef test_non_200_errors_report_browsersteps(client, live_server, measure_memory_usage, datastore_path):\n\n    four_o_four_url =  url_for('test_endpoint', status_code=404, _external=True)\n    four_o_four_url = four_o_four_url.replace('localhost.localdomain', 'cdio')\n    four_o_four_url = four_o_four_url.replace('localhost', 'cdio')\n\n    res = client.post(\n        url_for(\"ui.ui_views.form_quick_watch_add\"),\n        data={\"url\": four_o_four_url, \"tags\": '', 'edit_and_watch_submit_button': 'Edit > Watch'},\n        follow_redirects=True\n    )\n\n    assert b\"Watch added in Paused state, saving will unpause\" in res.data\n    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), \"Needs PLAYWRIGHT_DRIVER_URL set for this test\"\n\n    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))\n\n    # now test for 404 errors\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid, unpause_on_save=1),\n        data={\n              \"url\": four_o_four_url,\n              \"tags\": \"\",\n              'fetch_backend': \"html_webdriver\",\n              'browser_steps-0-operation': 'Click element',\n              'browser_steps-0-selector': 'button[name=test-button]',\n              'browser_steps-0-optional_value': '',\n              \"time_between_check_use_default\": \"y\"\n        },\n        follow_redirects=True\n    )\n    assert b\"unpaused\" in res.data\n\n    wait_for_all_checks(client)\n\n    res = client.get(url_for(\"watchlist.index\"))\n\n    assert b'Error - 404' in res.data\n\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n\ndef test_browsersteps_edit_UI_startsession(client, live_server, measure_memory_usage, datastore_path):\n\n    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), \"Needs PLAYWRIGHT_DRIVER_URL set for this test\"\n\n    # Add a watch first\n    test_url = url_for('test_interactive_html_endpoint', _external=True)\n    test_url = test_url.replace('localhost.localdomain', 'cdio')\n    test_url = test_url.replace('localhost', 'cdio')\n\n    uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'fetch_backend': 'html_webdriver', 'paused': True})\n\n    # Test starting a browsersteps session\n    res = client.get(\n        url_for(\"browser_steps.browsersteps_start_session\", uuid=uuid),\n        follow_redirects=True\n    )\n\n    assert res.status_code == 200\n    assert res.is_json\n    json_data = res.get_json()\n    assert 'browsersteps_session_id' in json_data\n    assert json_data['browsersteps_session_id']  # Not empty\n\n    browsersteps_session_id = json_data['browsersteps_session_id']\n\n    # Verify the session exists in browsersteps_sessions\n    from changedetectionio.blueprint.browser_steps import browsersteps_sessions, browsersteps_watch_to_session\n    assert browsersteps_session_id in browsersteps_sessions\n    assert uuid in browsersteps_watch_to_session\n    assert browsersteps_watch_to_session[uuid] == browsersteps_session_id\n\n    # Verify browsersteps UI shows up on edit page\n    res = client.get(url_for(\"ui.ui_edit.edit_page\", uuid=uuid))\n    assert b'browsersteps-click-start' in res.data, \"Browsersteps manual UI shows up\"\n\n    # Session should still exist after GET (not cleaned up yet)\n    assert browsersteps_session_id in browsersteps_sessions\n    assert uuid in browsersteps_watch_to_session\n\n    # Test cleanup happens on save (POST)\n    res = client.post(\n        url_for(\"ui.ui_edit.edit_page\", uuid=uuid),\n        data={\n            \"url\": test_url,\n            \"tags\": \"\",\n            'fetch_backend': \"html_webdriver\",\n            \"time_between_check_use_default\": \"y\",\n        },\n        follow_redirects=True\n    )\n    assert b\"Updated watch\" in res.data\n\n    # NOW verify the session was cleaned up after save\n    assert browsersteps_session_id not in browsersteps_sessions\n    assert uuid not in browsersteps_watch_to_session\n\n    # Cleanup\n    client.get(\n        url_for(\"ui.form_delete\", uuid=\"all\"),\n        follow_redirects=True\n    )\n"
  },
  {
    "path": "changedetectionio/time_handler.py",
    "content": "from functools import lru_cache\n\nimport arrow\nfrom enum import IntEnum\n\n\nclass Weekday(IntEnum):\n    \"\"\"Enumeration for days of the week.\"\"\"\n    Monday = 0\n    Tuesday = 1\n    Wednesday = 2\n    Thursday = 3\n    Friday = 4\n    Saturday = 5\n    Sunday = 6\n\ndef am_i_inside_time(\n        day_of_week: str,\n        time_str: str,\n        timezone_str: str,\n        duration: int = 15,\n) -> bool:\n    \"\"\"\n    Determines if the current time falls within a specified time range.\n\n    Parameters:\n        day_of_week (str): The day of the week (e.g., 'Monday').\n        time_str (str): The start time in 'HH:MM' format.\n        timezone_str (str): The timezone identifier (e.g., 'Europe/Berlin').\n        duration (int, optional): The duration of the time range in minutes. Default is 15.\n\n    Returns:\n        bool: True if the current time is within the time range, False otherwise.\n    \"\"\"\n    # Parse the target day of the week\n    try:\n        target_weekday = Weekday[day_of_week.capitalize()]\n    except KeyError:\n        raise ValueError(f\"Invalid day_of_week: '{day_of_week}'. Must be a valid weekday name.\")\n\n    # Parse the start time\n    try:\n        hour, minute = map(int, time_str.split(':'))\n        if not (0 <= hour <= 23 and 0 <= minute <= 59):\n            raise ValueError\n    except (ValueError, AttributeError):\n        raise ValueError(f\"Invalid time_str: '{time_str}'. Must be in 'HH:MM' format.\")\n\n    # Get the current time in the specified timezone\n    try:\n        now_tz = arrow.now(timezone_str.strip())\n    except Exception as e:\n        raise ValueError(f\"Invalid timezone_str: '{timezone_str}'. Must be a valid timezone identifier.\")\n\n    # Check if the current day matches the target day or overlaps due to duration\n    current_weekday = now_tz.weekday()\n    # Create start datetime for today in target timezone\n    start_datetime_tz = now_tz.replace(hour=hour, minute=minute, second=0, microsecond=0)\n\n    # Handle previous day's overlap\n    if target_weekday == (current_weekday - 1) % 7:\n        # Calculate start and end times for the overlap from the previous day\n        start_datetime_tz = start_datetime_tz.shift(days=-1)\n        end_datetime_tz = start_datetime_tz.shift(minutes=duration)\n        if start_datetime_tz <= now_tz <= end_datetime_tz:\n            return True\n\n    # Handle current day's range\n    if target_weekday == current_weekday:\n        end_datetime_tz = start_datetime_tz.shift(minutes=duration)\n        if start_datetime_tz <= now_tz <= end_datetime_tz:\n            return True\n\n    # Handle next day's overlap\n    if target_weekday == (current_weekday + 1) % 7:\n        end_datetime_tz = start_datetime_tz.shift(minutes=duration)\n        if now_tz < start_datetime_tz and now_tz.shift(days=1) <= end_datetime_tz:\n            return True\n\n    return False\n\n\ndef is_within_schedule(time_schedule_limit, default_tz=\"UTC\"):\n    \"\"\"\n    Check if the current time is within a scheduled time window.\n\n    Parameters:\n        time_schedule_limit (dict): Schedule configuration with timezone, day settings, etc.\n        default_tz (str): Default timezone to use if not specified. Default is 'UTC'.\n\n    Returns:\n        bool: True if current time is within the schedule, False otherwise.\n    \"\"\"\n    if time_schedule_limit and time_schedule_limit.get('enabled'):\n        # Get the timezone the time schedule is in, so we know what day it is there\n        tz_name = time_schedule_limit.get('timezone')\n        if not tz_name:\n            tz_name = default_tz\n\n        # Get current day name in the target timezone\n        now_day_name_in_tz = arrow.now(tz_name.strip()).format('dddd')\n        selected_day_schedule = time_schedule_limit.get(now_day_name_in_tz.lower())\n        if not selected_day_schedule.get('enabled'):\n            return False\n\n        duration = selected_day_schedule.get('duration')\n        selected_day_run_duration_m = int(duration.get('hours')) * 60 + int(duration.get('minutes'))\n\n        is_valid = am_i_inside_time(day_of_week=now_day_name_in_tz,\n                                    time_str=selected_day_schedule['start_time'],\n                                    timezone_str=tz_name,\n                                    duration=selected_day_run_duration_m)\n\n        return is_valid\n\n    return False\n"
  },
  {
    "path": "changedetectionio/translations/README.md",
    "content": "# Translation Guide\n\n## Updating Translations\n\nTo maintain consistency and minimize unnecessary changes in translation files, run these commands:\n\n```bash\npython setup.py extract_messages   # Extract translatable strings\npython setup.py update_catalog     # Update all language files\npython setup.py compile_catalog    # Compile to binary .mo files\n```\n\n## Configuration\n\nAll translation settings are configured in **`../../setup.cfg`** (single source of truth).\n\nThe configuration below is shown for reference - **edit `setup.cfg` to change settings**:\n\n```ini\n[extract_messages]\n# Extract translatable strings from source code\nmapping_file = babel.cfg\noutput_file = changedetectionio/translations/messages.pot\ninput_paths = changedetectionio\nkeywords = _ _l gettext\n# Options to reduce unnecessary changes in .pot files\nsort_by_file = true       # Keeps entries ordered by file path\nwidth = 120               # Consistent line width (prevents rewrapping)\nadd_location = file       # Show file path only (not line numbers)\n\n[update_catalog]\n# Update existing .po files with new strings from .pot\n# Note: 'locale' is omitted - Babel auto-discovers all catalogs in output_dir\ninput_file = changedetectionio/translations/messages.pot\noutput_dir = changedetectionio/translations\ndomain = messages\n# Options for consistent formatting\nwidth = 120               # Consistent line width\nno_fuzzy_matching = true  # Avoids incorrect automatic matches\n\n[compile_catalog]\n# Compile .po files to .mo binary format\ndirectory = changedetectionio/translations\ndomain = messages\n```\n\n**Key formatting options:**\n- `sort_by_file = true` - Orders entries by file path (consistent ordering)\n- `width = 120` - Fixed line width prevents text rewrapping\n- `add_location = file` - Shows file path only, not line numbers (reduces git churn)\n- `no_fuzzy_matching = true` - Prevents incorrect automatic fuzzy matches\n\n## Why Use These Commands?\n\nRunning pybabel commands directly without consistent options causes:\n- ❌ Entries get reordered differently each time\n- ❌ Text gets rewrapped at different widths\n- ❌ Line numbers change every edit (if not configured)\n- ❌ Large diffs that make code review difficult\n\nUsing `python setup.py` commands ensures:\n- ✅ Consistent ordering (by file path, not alphabetically)\n- ✅ Consistent line width (120 characters, no rewrapping)\n- ✅ File-only locations (no line number churn)\n- ✅ No fuzzy matching (prevents incorrect auto-translations)\n- ✅ Minimal diffs (only actual changes show up)\n- ✅ Easier code review and git history\n\nThese commands read settings from `../../setup.cfg` automatically.\n\n## Supported Languages\n\n- `cs` - Czech (Čeština)\n- `de` - German (Deutsch)\n- `en_GB` - English (UK)\n- `en_US` - English (US)\n- `fr` - French (Français)\n- `it` - Italian (Italiano)\n- `ko` - Korean (한국어)\n- `zh` - Chinese Simplified (中文简体)\n- `zh_Hant_TW` - Chinese Traditional (繁體中文)\n\n## Adding a New Language\n\n1. Initialize the new language catalog:\n   ```bash\n   pybabel init -i changedetectionio/translations/messages.pot -d changedetectionio/translations -l NEW_LANG_CODE\n   ```\n2. Compile it:\n   ```bash\n   python setup.py compile_catalog\n   ```\n\nBabel will auto-discover the new language on subsequent translation updates.\n\n## Translation Notes\n\nFrom CLAUDE.md:\n- Always use \"monitor\" or \"watcher\" terminology (not \"clock\")\n- Use the most brief wording suitable\n- When finding issues in one language, check ALL languages for the same issue\n"
  },
  {
    "path": "changedetectionio/translations/cs/LC_MESSAGES/messages.po",
    "content": "# Czech translations for PROJECT.\n# Copyright (C) 2026 ORGANIZATION\n# This file is distributed under the same license as the PROJECT project.\n# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PROJECT VERSION\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-02 11:40+0100\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language: cs\\n\"\n\"Language-Team: cs <LL@li.org>\\n\"\n\"Plural-Forms: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"A backup is running!\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"Mb\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"No backups found.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"Vytvořit zálohu\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"Odstranit zálohy\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"Seznam adres URL\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX a Wachete\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"Příklad:\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"URL, které neprojdou validací, zůstanou v textové oblasti.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"Tohle je\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"experimentální\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"podporovaná pole jsou\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"zbytek (včetně\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"jsou ignorovány.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"Jak exportovat?\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"\"\n\"V případě potřeby nezapomeňte nastavit výchozí nástroj pro načítání na Chrome.V případě potřeby nezapomeňte nastavit \"\n\"výchozí načítání na Chrome.V případě potřeby nezapomeňte nastavit výchozí nástroj pro načítání na Chrome.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"Tabulka vlastního mapování sloupců a datových typů pro\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"Vlastní mapování\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"Typ mapování souboru.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"sloupec č.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"Typ\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"žádný\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"CSS/xPath filtr\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"Sledovat skupinu / tag\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"Znovu zkontrolovat čas (minuty)\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"IMPORTOVAT\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"Upozornění: Počet workerů ({}) se blíží nebo překračuje dostupné CPU jádra ({})\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"NASTAVENÍ\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"Všechna oznámení ztlumena.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"Všechna oznámení odtlumena.\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"Protokol ladění oznámení\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"Generál\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"Načítání\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"Globální filtry\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"Možnosti uživatelského rozhraní\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"Backups\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"Čas a datum\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"CAPTCHA a proxy\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"Info\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"Výchozí čas opětovné kontroly pro všechny monitory, aktuální systémové minimum je\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"Více informací\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"Po tolika po sobě jdoucích případech, kdy CSS/xPath filtr chybí, odeslat oznámení\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"Nastavit na\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"\"\n\"Povolit přístup na stránku historie změn monitoru, když je povoleno heslo (Vhodné pro sdílení stránky rozdílů)Povolit\"\n\" anonymní přístup na stránku historie sledování, když je povoleno heslo\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"Vyberte výchozí proxy pro všechny monitory\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"token v odkazech oznámení.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"přečtěte si více zde\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"Použijte\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"Základní\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"The\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"Chrome/Javascript\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"Tohle počká\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"sekund před extrahováním textu.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"Aktuálně běží:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"funkční\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"pracovníci\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"aktivně zpracovává\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"Tip:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"Připojte se pomocí Bright Data a Oxylabs Proxies, více se dozvíte zde.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"Poznámka:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"ignorováno\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"Přístup k API a příklady zde\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"API klíč\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Rozšíření pro Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Chrome Webstore\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"Tip\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"Verze Pythonu:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"Pluginy aktivní:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"Nejsou aktivní žádné pluginy\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"Zpět\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"Vymazat historii snímků\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"Přidáno\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"Ztlumit\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"Filtry a spouštěče\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"NASTAVENÍ\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"přidáno\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"do všech existujících konfigurací monitorů.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"Filtrování textu\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"Používejte opatrně!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"Snadno tak zaplníte kvótu e-mailového úložiště nebo zahltíte další úložiště.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"ODHLÁSIT SE\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"ODHLÁSIT SE\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"Existují\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"povoleny adresy URL pro upozornění v celém systému\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"tento formulář přepíše nastavení oznámení pouze pro tyto monitory\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"prázdný seznam adres URL upozornění zde bude stále odesílat upozornění.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"Použít výchozí nastavení systému\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"Přidejte novou značku organizace\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"Skupina / Značka\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"# monitorů\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"Název štítku / štítku\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"Žádné skupiny/značky\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"Upravit\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"Znovu zkontrolujte\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"Smazat skupinu?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"Vymazat\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"Smaže a odstraní značku\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"Odpojit skupinu?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"Odpojit\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"Ponechte štítek, ale odpojte všechny monitory\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"RSS kanál pro tyto monitory\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"{} monitorů pozastaveno\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"{} monitorů ztlumeno\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"{} monitorů nastaveno na použití výchozího nastavení oznámení\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"Sledujte tuto adresu URL!\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"Historie snímků vymazána pro monitor {}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"Žádné informace\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"Vymazat\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"Do fronty přidáno {} monitorů k opětovné kontrole ({} již ve frontě nebo běží).\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"Do fronty přidáno {} sledování k opětovné kontrole.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"Přidávání monitorů do fronty pro opětovnou kontrolu na pozadí...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"Jazyk nastaven na automatickou detekci z prohlížeče\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"Smazat monitory?\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"Možná budete chtít použít\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"BACKUP\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"nejprve odkaz.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"Potvrzovací text\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"Zadejte slovo\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"jasný\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"potvrdit, že rozumíte.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"Vymazat historii!\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"Zrušit\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"Sdílet rozdíl jako obrázek\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"Sdílet jako obrázek\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"Ignorujte všechny odpovídající řádky\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"Ignorujte všechny odpovídající řádky kromě číslic\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"Z\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"Na\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"Slova\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"Řádky\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"Ignorujte mezery\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"Stejné/beze změny\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"Odebráno\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"Přidáno\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"Vyměněno\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"Klávesnice:\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"Náhled\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"Další\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"Přejít na další rozdíl\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"Skok\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"Text chyby\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"Snímek obrazovky s chybou\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"Text\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"Aktuální snímek obrazovky\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"Extrahujte data\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"před sekundami.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"před sekundami\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"Aktuální snímek obrazovky s chybou z posledního požadavku\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"Pro-tip: Můžete povolit\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"NASTAVENÍ\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"Přejít na jeden snímek\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"Zvýrazněte text, který chcete sdílet nebo přidat do seznamů ignorovaných.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"Aktuální snímek obrazovky z poslední žádosti\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"Zatím není k dispozici žádný snímek obrazovky! Zkuste stránku znovu zkontrolovat.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"Snímek obrazovky vyžaduje aktivaci nástroje Playwright/WebDriver\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"Žádost\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"Kroky prohlížeče\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"Volič vizuálního filtru\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"Podmínky\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"Statistiky\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"Některé stránky používají k vytváření obsahu JavaScript, proto byste měli\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"použijte nástroj Chrome/WebDriver Fetcher\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"V URL jsou podporovány proměnné\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"nápověda a příklady zde\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"Název skupiny/značky\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"Interval/doba mezi jednotlivými kontrolami.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"Znovu zkontrolujte vše\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"RSS kanál pro tyto monitory\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"Použití aktuálního globálního výchozího nastavení\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"Zobrazit pokročilé možnosti\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"Další nápověda a příklady zde\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"V těle požadavku jsou podporovány proměnné\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"Proměnné jsou podporovány v hodnotách hlavičky požadavku\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"Upozornění! Byl nalezen další soubor záhlaví a bude přidán do těchto monitorů!\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"Záhlaví lze také číst ze souboru ve vašem datovém adresáři\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"Přečtěte si více zde\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"Není podporováno prohlížečem Selenium\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"Zapněte vyhledávač textu\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"Čekejte prosím, načtení prvního kroku prohlížeče může chvíli trvat.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"Začněte kliknutím sem\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"Počkejte prosím 10–15 sekund, než se prohlížeč připojí.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"Data Visual Selector nejsou připravena, monitory je třeba alespoň jednou zkontrolovat.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"na ten, který podporuje interaktivní Javascript.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"musíte\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"Nastavte metodu načítání\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"Přečtěte si rychlý tutoriál o\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"pomocí podmíněných změn webové stránky zde\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"Náhled\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"Pro-tipy:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"Pomocí stránky náhledu uvidíte zvýrazněné filtry a spouštěče.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"Omezit spouštění/ignorovat/blokovat/extrahovat na;\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"místo\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"nahrazení\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"například.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"přidání\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"Vždy je tedy lepší vybírat\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"když vás zajímá nový obsah.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"Když se obsah pouze přesune v seznamu, spustí se také\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"zvážit povolení\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"Spustit pouze tehdy, když se objeví jedinečné čáry\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"zkontrolujte jedinečné řádky\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"níže.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"Odstraňte všechny mezery před a za každým řádkem textu\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"Načítání...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"Nástroj Visual Selector umožňuje vybrat\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"text\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"Pauza\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"Shift+kliknutí\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"pro výběr více položek.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"Režim výběru:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"Vyberte podle prvku\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"Kreslit oblast\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"Jasný výběr\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"Okamžik, načítání snímku obrazovky a informací o prvku.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"V současné době:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"na ten, který podporuje Javascript a snímky obrazovky.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"Zkontrolujte počet\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"Následná selhání filtru\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"Dějiny\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"Trvání posledního načtení\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"Počet upozornění na upozornění\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"Odpověď typu serveru\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"Stáhněte si nejnovější HTML snímek\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"Smazat monitory?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"Opravdu chcete smazat monitory pro:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"Tuto akci nelze vrátit zpět.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"Vymazat historii?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"Opravdu chcete vymazat celou historii pro:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"Vymazat historii\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"Klonovat a upravovat\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"Vyberte časové razítko\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"Jít\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"Aktuální chybový snímek obrazovky z posledního požadavku\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"zobrazeno <b>{start} - {end}</b> {record_name} z celkem <b>{total}</b>\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"záznamy\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"Více informací\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"Přidejte nové monitory zjišťování změn webové stránky\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"Monitorovat tuto URL!\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"Upravit a monitorovat\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"Pauza\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"Zrušit pozastavení\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"Ztlumit\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"Zrušit ztlumení\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"Štítek\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"Mark zobrazil\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"Použít výchozí oznámení\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"Vymazat chyby\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"Vymazat historie\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"OK\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"Vymazat/resetovat historii\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"Smazat monitory?\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"Velikost fronty\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"Hledání\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"Vše\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"webové stránky\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"Doplnění zásob a cena\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"Zkontrolováno\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"Poslední\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"Změněno\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"Nejsou nakonfigurována žádná sledování webových stránek, do výše uvedeného pole přidejte adresu URL nebo\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"importovat seznam\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"Detekce zásob a ceny\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"Skladem\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"Není skladem\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"Cena\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"Žádné informace\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"Probíhá kontrola\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"Ve frontě\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"Historie\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"Náhled\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"S chybami\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"Označit všechny prohlížené\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"Označit vše prohlížené v '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"Nepřečtený\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"Znovu zkontrolujte vše\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"v '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"Ještě ne\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"sekundy\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"Nesprávné heslo\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"Neplatný formát času. Použijte HH:MM.\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"Neplatný název časového pásma\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"nenastaveno\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"Začíná v\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"Doba běhu\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"Použijte časový plánovač\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"Volitelné časové pásmo pro spuštění\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"pondělí\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"úterý\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"středa\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"čtvrtek\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"pátek\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"sobota\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"neděle\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"týdny\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"Mělo by obsahovat nula nebo více sekund\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"Dny\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"Hodiny\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"Minuty\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"sekundy\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"Při použití adresy URL oznámení je vyžadováno tělo a název oznámení\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"'%s' není platná URL AppRise.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"RegEx '%s' není platný regulární výraz.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"'%s' není platný výraz XPath. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"'%s' není platný výraz JSONPath. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"'%s' není platný výraz jq. (%s)\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"Prázdná hodnota není povolena.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"Neplatná hodnota.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"Skupina / Značka\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"Monitorovat\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"Procesor\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"Upravit > Monitorovat\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"Nastavte metodu načítání\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"Text oznámení\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"Formát oznámení\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"Název oznámení\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"Seznam URL oznámení\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"Procesor – Čeho chcete dosáhnout?\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"Výchozí časové pásmo pro plánovač kontroly monitorů\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"sekund před extrahováním textu.\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"Měl by obsahovat jednu nebo více sekund\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"Nahrajte soubor .xlsx\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"Musí to být soubor .xlsx!\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"Typ mapování souboru.\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"Možnosti uživatelského rozhraní\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"Režim výběru:\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"Pauza\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"Interval mezi kontrolami\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"Použijte globální nastavení pro čas mezi kontrolou a plánovačem.\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"CSS/xPath filtr\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"Vyberte podle prvku\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"Extrahujte data\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"Titul\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"Ignorujte všechny odpovídající řádky\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"Žádost\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"Žádost\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"Ignorovat stavové kódy (zpracovat stavové kódy jiné než 2xx jako normálně)\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"Spustit pouze tehdy, když se objeví jedinečné čáry\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"Odstraňte duplicitní řádky textu\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"Seřadit text podle abecedy\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"Odstraňte ignorované řádky\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"Odstraňte všechny mezery před a za každým řádkem textu\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"Přidané řádky\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"Vyměněny/změněny řádky\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"Odebráno\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"Spouštěče klíčových slov – Spouštění/čekání na text\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"Blokovat detekci změn, když se text shoduje\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"Spusťte JavaScript před detekcí změn\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"Uložit\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"Proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"Odeslat upozornění, když filtr již na stránce nelze najít\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"Ztlumit\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"Zapnuto\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"Oznámení\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"Připojte snímek obrazovky k oznámení (pokud je to možné)\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"# monitory\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"Spojte všechny následující položky\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"Přiřaďte kteroukoli z následujících možností\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"V seznamu použijte stránku <title>\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"Když je metoda požadavku nastavena na GET, tělo musí být prázdné\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"Neplatná konfigurace syntaxe šablony: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"Neplatná syntaxe šablony: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"Název\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"Proxy URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"Proxy URL musí začínat http://, https:// nebo socks5://\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"Adresa URL připojení prohlížeče\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"Adresy URL prohlížeče musí začínat wss:// nebo ws://\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"Požadavky na prostý text\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Chrome požadavky\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"Výchozí proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"Náhodné jitter sekundy ± kontrola\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"Počet pracovníků aportů\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"Mělo by být mezi 1 a 50\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"Požaduje časový limit v sekundách\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"Mělo by být mezi 1 a 999\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"Výchozí přepisy User-Agent\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"Je vyžadován název i adresa URL proxy.\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"Otevřete stránku „Historie“ na nové kartě\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"Aktualizace v reálném čase offline\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"zvážit povolení\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"Použijte stránku <title> v přehledu sledování\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"Kontrola zabezpečení přístupového tokenu API povolena\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"Základní URL pro upozornění\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"Považovat prázdné stránky za změnu?\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"Text chyby\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"Ignorujte mezery\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"Musí být mezi 0 a 100\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"Heslo\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"Velikost pageru\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"Mělo by být alespoň nula (vypnuto)\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"Formát obsahu RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"RSS <description> tělo sestavené z\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"Odebrat heslo\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"Vykreslit obsah kotvící značky\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"Povolit anonymní přístup na stránku historie sledování, když je povoleno heslo\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"Skrýt ztlumené monitory ze zdroje RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"Povolit režim čtečky RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"Počet změn, které se mají zobrazit v kanálu RSS sledování\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"Mělo by obsahovat nula nebo více pokusů\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"Kolikrát může filtr chybět před odesláním upozornění\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"RegEx k extrahování\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"Extrahujte data\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"Hodnota ohraničujícího rámečku je příliš dlouhá\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"Ohraničovací rámeček musí být ve formátu: x,y,šířka,výška (pouze celá čísla)\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"Hodnoty ohraničujícího rámečku musí být nezáporné\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"Hodnoty ohraničujícího rámečku jsou příliš velké\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"Procento minimální změny\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"Rozdílová citlivost pixelů\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"Použít výchozí nastavení systému\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"Bounding Box\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"Režim výběru:\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"Hodnota režimu výběru je příliš dlouhá\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"Porovnání snímků obrazovky\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"Vizuální / Obrazová detekce změny snímku obrazovky\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"Detekce doplnění zásob\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"Pouze skladem (Není skladem -> Pouze skladem)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"Jakékoli změny dostupnosti\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"Vypnuto, nesledujte dostupnost/doskladnění\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"Pod cenou pro spuštění upozornění\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"Bez omezení\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"Vyšší cena pro spuštění upozornění\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"Prahová hodnota v % pro změny ceny od původní ceny\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"Mělo by být mezi 0 a 100\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"Sledujte změny cen\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"Doplnění zásob a cena\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"Doplnění zásob a zjištění ceny pro stránky s JEDINÝM produktem\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"Zjistí, zda se produkt vrátí na sklad\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"Změny textu webové stránky/HTML, JSON a PDF\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"Detekuje všechny změny textu, kde je to možné\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"Tělo pro všechna oznámení — Můžete použít\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"Zobrazit tokeny/zástupné symboly\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"Token\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"Popis\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"UUID monitoru.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"Skupina / značka monitoru\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"a\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"Více zde\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"URL pro AppRise oznámení\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"Použít\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"Zobrazit pokročilou nápovědu a tipy\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"textu oznámení včetně názvu.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"další nápověda zde\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"Odeslat testovací oznámení\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"Přidat email\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"Přidat emailovou adresu\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"Protokoly ladění oznámení\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"Zpracovává se..\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"Nadpis pro všechna oznámení\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"například -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"Formát pro všechna oznámení\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"Akce\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"Možná budete muset\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"v\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"soubor\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"Časové limity plánu\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"Víkendy\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"Resetovat\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"Další nápověda a příklady použití plánovače\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"Chcete použít časový plán?\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"Spouštěcí text\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"Ignorovaný text\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"Nedojde k detekci změn, protože tento text existuje.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"Blokovaný text\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"Vyhledejte nebo použijte klávesu Alt+S\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"Aktualizace v reálném čase offline\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"Vyberte Jazyk\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"Automaticky zjistit z prohlížeče\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"Hledat\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"v\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"informace zde\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"Přihlášení\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"SKUPINY\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"NASTAVENÍ\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"IMPORTOVAT\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"Odtlumit oznámení\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"Ztlumit oznámení\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"Oznámení jsou ztlumena - klikněte pro odtlumení\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"UPRAVIT\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"ODHLÁSIT SE\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"Přepnout režim Světlý/Tmavý\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"Přepínání mezi světlým/tmavým režimem\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"Změnit jazyk\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"Změnit jazyk\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"Ano\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"Ne\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"Hlavní nastavení\"\n\n#~ msgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\n#~ msgstr \"Porovnává snímky obrazovky pomocí rychlého algoritmu OpenCV, 10-100x rychlejší než SSIM\"\n\n#~ msgid \"Actions\"\n#~ msgstr \"Podmínky\"\n\n#~ msgid \"You may need to\"\n#~ msgstr \"musíte\"\n\n#~ msgid \"in the\"\n#~ msgstr \"The\"\n\n#~ msgid \"file\"\n#~ msgstr \"Titul\"\n\n#~ msgid \"Schedule time limits\"\n#~ msgstr \"Znovu zkontrolovat čas (minuty)\"\n\n#~ msgid \"Weekends\"\n#~ msgstr \"týdny\"\n\n#~ msgid \"Reset\"\n#~ msgstr \"Žádost\"\n\n#~ msgid \"More help and examples about using the scheduler\"\n#~ msgstr \"Další nápověda a příklady zde\"\n\n#~ msgid \"Want to use a time schedule?\"\n#~ msgstr \"Použijte časový plánovač\"\n\n#~ msgid \"Triggered text\"\n#~ msgstr \"Text chyby\"\n\n#~ msgid \"Ignored text\"\n#~ msgstr \"Text chyby\"\n\n#~ msgid \"No change-detection will occur because this text exists.\"\n#~ msgstr \"Blokovat detekci změn, když se text shoduje\"\n\n#~ msgid \"Blocked text\"\n#~ msgstr \"Text chyby\"\n\n#~ msgid \"Search\"\n#~ msgstr \"Hledání\"\n\n#~ msgid \"in\"\n#~ msgstr \"Více informací\"\n\n#~ msgid \"Visual\"\n#~ msgstr \"Vizuální\"\n\n#~ msgid \"Restock\"\n#~ msgstr \"Doplnění zásob\"\n\n#~ msgid \"Watch List\"\n#~ msgstr \"Seznam monitorů\"\n\n#~ msgid \"Watches\"\n#~ msgstr \"Monitory\"\n\n#~ msgid \"Queue\"\n#~ msgstr \"Ve frontě\"\n\n#~ msgid \"Cleared snapshot history for all watches\"\n#~ msgstr \"Vymazat/resetovat historii\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"Vytvořte odkaz ke sdílení\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"Tip: Můžete také přidat „sdílené“ monitory.\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/translations/de/LC_MESSAGES/messages.po",
    "content": "# German translations for PROJECT.\n# Copyright (C) 2026 ORGANIZATION\n# This file is distributed under the same license as the PROJECT project.\n# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PROJECT VERSION\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-14 03:57+0100\\n\"\n\"Last-Translator: \\n\"\n\"Language: de\\n\"\n\"Language-Team: de <LL@li.org>\\n\"\n\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"Ein Backup läuft bereits, versuchen Sie es in ein paar Minuten erneut\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"Maximale Anzahl an Backups erreicht, bitte entfernen Sie einige\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"Backup läuft im Hintergrund, bitte in ein paar Minuten erneut versuchen.\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"Backups wurden gelöscht.\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"Ein Backup läuft!\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"\"\n\"Hier können Sie ein neues Backup herunterladen und anfordern. Sobald ein Backup abgeschlossen ist, wird es unten \"\n\"aufgelistet.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"Mb\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"Keine Backups gefunden.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"Backup erstellen\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"Backups entfernen\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"Es werden 5.000 der ersten URLs aus Ihrer Liste importiert, der Rest kann erneut importiert werden.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"{} aus Liste importiert in {:.2f}s, {} übersprungen.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"JSON-Datei kann nicht gelesen werden, ist sie beschädigt?\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"JSON-Struktur sieht ungültig aus, ist sie beschädigt?\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"{} aus Distill.io importiert in {:.2f}s, {} übersprungen.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"XLSX-Datei kann nicht gelesen werden, stimmt etwas mit der Datei nicht?\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"Fehler bei der Verarbeitung von Zeile {}, URL-Wert war falsch, Zeile wurde übersprungen.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"Fehler bei der Verarbeitung von Zeile {}, prüfen Sie, ob alle Zelldatentypen korrekt sind, Zeile wurde übersprungen.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"{} aus Wachete .xlsx importiert in {:.2f}s\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"{} aus benutzerdefinierter .xlsx importiert in {:.2f}s\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"URL-Liste\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX & Wachete\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"\"\n\"Geben Sie eine URL pro Zeile ein und fügen Sie optional Tags für jede URL nach einem Leerzeichen hinzu, getrennt \"\n\"durch Kommas (,):\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"Beispiel:\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"URLs, die die Validierung nicht bestehen, bleiben im Textbereich.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"\"\n\"Kopieren Sie Ihre Distill.io-Watch-„Export“-Datei und fügen Sie sie ein. Dabei sollte es sich um eine JSON-Datei \"\n\"handeln.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"Das ist\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"Experimental-\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"Unterstützte Felder sind\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"der Rest (inkl\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"werden ignoriert.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"Wie exportiere ich?\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"Stelle sicher, dass der Standard-Fetcher auf Chrome gesetzt ist, falls erforderlich.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"Tabelle der benutzerdefinierten Spalten- und Datentypzuordnung für\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"Benutzerdefinierte Zuordnung\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"Dateizuordnungstyp.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"Spalte #\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"Typ\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"keiner\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"CSS/xPath-Filter\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"Gruppe/Tag beobachten\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"Nachprüfzeit (Minuten)\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"IMPORT\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"Passwortschutz entfernt.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"Warnung: Anzahl der Worker ({}) nähert sich oder überschreitet die verfügbaren CPU-Kerne ({})\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"Anzahl der \\\"Worker\\\" geändert auf: {}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"Die dynamische Anpassung von „Worker\\\" wird für Synchronisierungs-„Worker\\\" nicht unterstützt.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"Fehler beim Anpassen der \\\"Worker\\\": {}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"Passwortschutz aktiviert.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"Einstellungen aktualisiert.\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"Ein Fehler ist aufgetreten, siehe unten.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"Der API-Schlüssel wurde neu generiert.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"Alle Benachrichtigungen stummgeschaltet.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"Alle Benachrichtigungen entstummt.\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"Benachrichtigungs-Debug-Protokoll\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"Allgemein\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"Abrufen\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"Globale Filter\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"UI-Optionen\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"Backups\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"Uhrzeit und Datum\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"CAPTCHA & Proxys\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"Info\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"Standardmäßige Überprüfungszeit für alle Observationen, derzeitiges Systemminimum ist\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"Weitere Informationen\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"\"\n\"Nach dieser Anzahl aufeinanderfolgender Male, dass der CSS/xPath-Filter fehlt, eine Benachrichtigung \"\n\"sendenHäufigkeit, mit der der Filter fehlen darf, bevor eine Benachrichtigung gesendet wird\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"Setzen auf\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"\"\n\"Zugriff auf die Änderungshistorie-Seite erlauben, wenn Passwort aktiviert ist (Gut zum Teilen der Diff-Seite)Erlauben\"\n\" Sie anonymen Zugriff auf die Seite mit dem Wiedergabeverlauf, wenn das Passwort aktiviert ist\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"Wählen Sie einen Standard-Proxy für alle Überwachungen\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"Token in Benachrichtigungslinks.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"hier mehr lesen\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"Methode (Standard), bei der Ihre überwachten Websites kein Javascript zum Rendern benötigen.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"Benutzen Sie die\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"Basic\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"\"\n\"Methode erfordert eine Netzwerkverbindung zu einem laufenden WebDriver+Chrome-Server, der durch die Umgebungsvariable\"\n\" festgelegt wird\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"Der\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"Chrome/Javascript\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\"Wenn Sie Probleme damit haben, dass die Seite vollständig gerendert wird (fehlender Text usw.), versuchen Sie, die \"\n\"Wartezeit hier zu verlängern.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"Das wird warten\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"Sekunden, bevor der Text extrahiert wird.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"Aktuell läuft:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"betriebsbereit\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"Worker\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"aktiv in Bearbeitung\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"Tipp:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"Verbinden Sie sich über Bright Data und Oxylabs Proxies. Weitere Informationen finden Sie hier.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"Hinweis:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"ignoriert\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"API-Zugriff und Beispiele hier\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"API-Schlüssel\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Chrome-Erweiterung\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Chrome Webstore\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"Tipp\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"Python-Version:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"Aktive Plugins:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"Keine Plugins aktiv\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"Zurück\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"Snapshot-Verlauf löschen\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"Das Tag „{}“ existiert bereits.\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"Tag hinzugefügt\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"Tag gelöscht, wird im Hintergrund aus Überwachungen entfernt\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"Tag nicht gefunden\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"Aktualisiert\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"Filter und Trigger\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"Diese Einstellungen sind\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"hinzugefügt\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"auf alle vorhandenen Überwachungskonfigurationen.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"Textfilterung\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"Mit Vorsicht verwenden!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"Dadurch wird Ihr E-Mail-Speicherkontingent leicht ausgeschöpft oder andere Speicher überflutet.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"Achtung!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"Achtung!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"Es gibt\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"Systemweite Benachrichtigungs-URLs aktiviert\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"Dieses Formular überschreibt die Benachrichtigungseinstellungen nur für diese Überwachung.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"Auch wenn die Liste der Benachrichtigungs-URLs hier leer ist, werden dennoch Benachrichtigungen versendet.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"Verwenden Sie Systemstandards\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"Fügen Sie ein neues Organisations-Tag hinzu\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"Gruppe / Label\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"\"\n\"Mit Gruppen können Sie Filter und Benachrichtigungen für mehrere Überwachungen unter einem einzigen Organisations-Tag\"\n\" verwalten.\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"# Überwachungen\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"Tag-/Labelname\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"Keine Gruppen/Labels konfiguriert\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"Bearbeiten\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"Neu prüfen\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"Gruppe löschen?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\"<p>Möchten Sie die Gruppe <strong>%(title)s</strong> wirklich löschen?</p><p>Diese Aktion kann nicht rückgängig \"\n\"gemacht werden.</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"Löschen\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"Löscht und entfernt den Tag\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"Gruppenverknüpfung aufheben?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\"<p>Möchten Sie wirklich alle Beobachtungen aus der Gruppe <strong>%(title)s</strong> entfernen?</p><p>Das Tag bleibt \"\n\"erhalten, aber die Beobachtungen werden daraus entfernt.\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"Verknüpfung aufheben\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"Behalte das Tag, aber entferne alle Verknüpfungen zu Überwachungen\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"RSS-Feed für diese Überwachung\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"{} Überwachungen gelöscht\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"{} Überwachungen pausiert\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"{} Überwachungen fortgesetzt\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"{} Überwachungen aktualisiert\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"{} Überwachungen stummgeschaltet\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"{} Überwachungen nicht stummgeschaltet\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"{} Überwachungen zur erneuten Überprüfung in der Warteschlange\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"{} Überwachungfehler behoben\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"{} Überwachungen gelöscht/zurückgesetzt.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"{} Überwachungen, die auf die Verwendung der Standardbenachrichtigungseinstellungen eingestellt sind\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"{} Überwachungen wurde markiert\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"Überwachung nicht gefunden\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"Snapshot-Verlauf für Beobachtung {} gelöscht\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"Falscher Bestätigungstext\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"Die Beobachtung mit der UUID {} existiert nicht.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"Gelöscht.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"Geklont, Sie bearbeiten die neue Beobachtung.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"Überwachung ist bereits in der Warteschlange oder wird gerade geprüft.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"1 Überwachung zur erneuten Überprüfung in Warteschlange gestellt.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"{} Überwachungen zur erneuten Überprüfung eingereiht ({} bereits in Warteschlange oder laufend).\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"In der Warteschlange {} zur erneuten Überprüfung.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"Überwachungen werden im Hintergrund zur erneuten Prüfung eingereiht...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"Die Freigabe konnte nicht erfolgen, bei der Kommunikation mit dem Freigabeserver ist ein Fehler aufgetreten – {}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"Sprache auf automatische Erkennung vom Browser gesetzt\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"Keinen Verlauf für den angegebenen Link gefunden, fehlerhafter Link?\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"Nicht genügend Verlauf (2 snapshots erforderlich), um die Änderungsseite für diese Überwachung anzuzeigen.\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"Keine Überwachungen zum Bearbeiten\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"Keine Überwachung mit der UUID {} gefunden.\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"In den Modus {} gewechselt.\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"Aktualisierte Überwachung – fortgesetzt!\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"Überwachung aktualisiert.\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"Vorschau nicht verfügbar – Kein Abruf/keine Überprüfung abgeschlossen oder Trigger nicht erreicht\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"\"\n\"Dadurch wird der Versionsverlauf (Snapshots) für ALLE Überwachungen gelöscht, aber Ihre Liste mit URLs bleibt \"\n\"erhalten!\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"Möglicherweise möchten Sie die verwenden\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"Backups\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"Link zuerst.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"Bestätigungstext\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"Geben Sie das Wort ein\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"löschen\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"um zu bestätigen, dass Sie es verstanden haben.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"Verlauf löschen!\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"Abbrechen\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"Diff als Bild teilen\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"Als Bild teilen\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"Ignorieren Sie alle übereinstimmenden Zeilen\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"Ignorieren Sie alle übereinstimmenden Zeilen mit Ausnahme von Ziffern\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"Von\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"Zu\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"Wörter\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"Zeilen\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"Leerzeichen ignorieren\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"Gleich/unverändert\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"Entfernt\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"Hinzugefügt\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"Ersetzt\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"Tastatur:\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"Zurück\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"Nächste\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"Springe zum nächsten Unterschied\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"Springen\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"Fehlertext\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"Fehler-Screenshot\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"Text\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"Aktueller Screenshot\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"Daten extrahieren\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"vor Sekunden.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"vor Sekunden\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"Aktueller Screenshot des Fehlers aus der letzten Anfrage\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"Profi-Tipp: Sie können es aktivieren\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"„Zugriff freigeben, wenn Passwort aktiviert ist“\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"aus den Einstellungen.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"Gehe zu einem einzelnen snapshot\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"Markieren Sie Text zum Teilen oder zum Hinzufügen zu Ignorierlisten.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"Derzeit werden Unterschiede nur textuell und nicht grafisch dargestellt, es ist nur der letzte Screenshot verfügbar.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"Aktueller Screenshot der letzten Anfrage\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"Derzeit ist kein Screenshot verfügbar! Versuchen Sie es später erneut.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"Für den Screenshot ist die Aktivierung von Playwright/WebDriver erforderlich\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"Anfrage\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"Browserschritte\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"Visuelle Filterauswahl\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"Bedingungen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"Statistiken\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"Einige Websites verwenden JavaScript, um den Inhalt zu erstellen. Dazu sollten Sie\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"Verwenden Sie den Chrome/WebDriver Fetcher\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"Variablen werden in der URL unterstützt\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"Hilfe und Beispiele finden Sie hier\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"Gruppen-/Label-NameGruppen-/Label-NameOrganisations-Tag/Gruppenname, der auf der Haupteintragsseite verwendet wird\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"\"\n\"Verwendet automatisch den Seitentitel, falls vorhanden. Sie können hier auch Ihren eigenen Titel/Ihre eigene \"\n\"Beschreibung verwenden.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"Das Intervall/die Zeitdauer zwischen den einzelnen Überprüfungen.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\"Sendet eine Benachrichtigung, wenn der Filter auf der Seite nicht mehr sichtbar ist. So wissen Sie, wann sich die \"\n\"Seite geändert hat und Ihr Filter nicht mehr funktioniert.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"Methode (default), bei der Ihre überwachte Website kein Javascript zum Rendern benötigt.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\"Die Methode erfordert eine Netzwerkverbindung zu einem laufenden WebDriver+Chrome-Server, der durch die \"\n\"Umgebungsvariable „WEBDRIVER_URL“ festgelegt wird.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"Überprüfen Sie alles noch einmal\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"Wählen Sie einen Proxy für diese Überwachung.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"Verwendung der aktuellen globalen Standardeinstellungen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"Erweiterte Optionen anzeigen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"\"\n\"Führen Sie diesen Code vor der Änderungserkennung aus. Er ist praktisch zum Ausfüllen von Feldern und für andere \"\n\"Aktionen.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"Weitere Hilfe und Beispiele finden Sie hier\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"Variablen werden im Anforderungstext unterstützt\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"Variablen werden in den Werten der Anfrage-Header unterstützt.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"Alarm! Zusätzliche Header-Datei gefunden und wird dieser Überwachung hinzugefügt!\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"Header können auch aus einer Datei in Ihrem Datenverzeichnis gelesen werden\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"Lesen Sie hier mehr\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"Wird vom Selenium-Browser nicht unterstützt\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"Schalten Sie den Textfinder ein\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"Bitte warten Sie, der erste Schritt des Browsers kann etwas Zeit zum Laden benötigen.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"Klicken Sie hier, um zu starten\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"Bitte warten Sie 10–15 Sekunden, bis der Browser eine Verbindung herstellt.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"Drücken Sie „Play“, um zu starten.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"Die Daten des visuellen Selektors sind noch nicht bereit, die Überwachung muss mindestens einmal überprüft werden.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\"Leider funktioniert diese Funktion nur mit Fetchern, die interaktives JavaScript unterstützen (bisher nur Playwright-\"\n\"basierte Fetcher).\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"zu einer, die interaktives Javascript unterstützt.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"Das musst du\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"Legen Sie die Fetch-Methode fest\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"\"\n\"Verwenden Sie die Schaltfläche „Überprüfen“ (✓), um zu testen, ob eine Bedingung für den aktuellen Snapshot erfüllt \"\n\"ist.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"Lesen Sie ein kurzes Tutorial darüber\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"Verwenden Sie hier bedingte Webseitenänderungen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"Vorschau aktivieren\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"Profi-Tipps:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"Verwenden Sie die Vorschauseite, um Ihre Filter und Auslöser hervorgehoben anzuzeigen.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"Auslösen/Ignorieren/Blockieren/Extrahieren beschränken auf;\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"Hinweis: Je nach Länge und Ähnlichkeit des Textes in jeder Zeile kann der Algorithmus eine\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"anstatt\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"Ersatz\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"Zum Beispiel.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"Zusatz\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"Es ist also immer besser, auszuwählen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"wenn Sie an neuen Inhalten interessiert sind.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"Wenn Inhalte lediglich in einer Liste verschoben werden, löst dies ebenfalls einen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"Erwägen Sie die Aktivierung\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"Wird nur ausgelöst, wenn eindeutige Zeilen angezeigt werden\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\"Gut geeignet für Websites, auf denen nur Inhalte verschoben werden und Sie wissen möchten, wann NEUE Inhalte \"\n\"hinzugefügt werden. Vergleicht neue Zeilen mit dem gesamten Verlauf dieser Überwachung.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"\"\n\"Hilft dabei, erkannte Änderungen zu reduzieren, die durch das Umstellen von Zeilen auf Websites verursacht werden, \"\n\"kombiniert mit\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"Überprüfen Sie eindeutige Zeilen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"unten.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"Entfernen Sie alle Leerzeichen vor und nach jeder Textzeile\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"Laden...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"Mit dem visuellen Auswahltool können Sie Folgendes auswählen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"Text\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\"Elemente, die für die Änderungserkennung verwendet werden. Es füllt automatisch die Filter im Feld „CSS/JSONPath/JQ\"\n\"/XPath-Filter” des\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"Tab. Verwendung\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"Umschalt+Klick\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"um mehrere Elemente auszuwählen.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"Auswahlmodus:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"Nach Element auswählen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"Bereich zeichnen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"Klare Auswahl\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"Einen Moment, Screenshot und Elementinformationen abrufen.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"Momentan:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"\"\n\"Leider funktioniert diese Funktion nur mit Fetchern, die Javascript und Screenshots unterstützen (wie z. B. \"\n\"Playwright usw.).\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"zu einer, die Javascript und Screenshots unterstützt.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"Anzahl prüfen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"Aufeinanderfolgende Filterausfälle\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"Länge des Verlaufs\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"Dauer des letzten Abrufs\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"Anzahl der Benachrichtigungsalarme\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"Antwort vom Servertyp\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"Laden Sie den neuesten HTML-Snapshot herunter\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"Überwachung löschen?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"Sind Sie sicher, dass Sie die Überwachung löschen möchten für:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"Diese Aktion kann nicht rückgängig gemacht werden.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"Verlauf löschen?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"Sind Sie sicher, dass Sie den gesamten Verlauf löschen möchten für:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"Dadurch werden alle Snapshots und früheren Versionen entfernt. Diese Aktion kann nicht rückgängig gemacht werden.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"Verlauf löschen\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"Klonen und bearbeiten\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"Zeitstempel auswählen\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"Gehen\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"Aktueller fehlerhafter Screenshot aus der letzten Anfrage\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"Für Screenshots ist ein Content Fetcher (Sockpuppetbrowser, Selenium usw.) erforderlich, der Screenshots unterstützt.\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"Achtung, die URL {} existiert bereits.\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"Die Überwachung wurde im Status „Pausiert“ hinzugefügt. Durch Speichern wird die Pause aufgehoben.\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"Überwachung hinzugefügt.\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"zeige <b>{start} - {end}</b> {record_name} von insgesamt <b>{total}</b>\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"Einträge\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"Weitere Informationen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"Fügen Sie eine neue Überwachung zur Erkennung von Webseitenänderungen hinzu\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"Diese URL überwachen!\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"Bearbeiten > Überwachen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"Pause\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"Pause aufheben\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"Stumm\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"Stummschaltung aufheben\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"Tag\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"Markierung angesehen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"Standardbenachrichtigung verwenden\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"Fehler löschen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"Verlauf löschen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\"<p>Möchten Sie den Verlauf für die ausgewählten Elemente wirklich löschen?</p><p>Diese Aktion kann nicht rückgängig \"\n\"gemacht werden.</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"OK\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"Verlauf löschen/zurücksetzen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"Überwachungen löschen?\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\"<p>Möchten Sie die ausgewählten Überwachungen wirklich löschen?</strong></p><p>Diese Aktion kann nicht rückgängig \"\n\"gemacht werden.</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"Warteschlangengröße\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"Suchen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"Alle\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"Webseite\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"Auffüllen und Preis\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"Geprüft\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"Zuletzt\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"Geändert\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"Es sind keine Website-Überwachungen konfiguriert. Bitte fügen Sie im Feld oben eine URL hinzu, oder\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"eine Liste importieren\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"Erkennen von Lagerbeständen und Preisen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"Auf Lager\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"Nicht auf Lager\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"Preis\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"Keine Informationen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"Jetzt prüfen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"Wartend\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"Verlauf\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"Vorschau\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"Mit Fehlern\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"Alle angesehen markieren\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"Alle in „%(title)s“ angezeigten Elemente markieren\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"Ungelesen\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"Überprüfen Sie alles noch einmal\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"in '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"Noch nicht\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"Sekunden\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"Bereits angemeldet\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"Sie müssen angemeldet sein, bitte melden Sie sich an.\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"Falsches Passwort\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"Es muss mindestens ein Zeitintervall (Wochen, Tage, Stunden, Minuten oder Sekunden) angegeben werden\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"\"\n\"Wenn keine globalen Einstellungen verwendet werden, muss mindestens ein Zeitintervall (Wochen, Tage, Stunden, Minuten\"\n\" oder Sekunden) angegeben werden.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"Ungültiges Format der Zeit. Verwenden Sie HH:MM.\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"Ungültiger Name der Zeitzone\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"nicht festgelegt\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"Startet um\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"Laufzeit\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"Verwenden Sie einen Zeitplaner\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"Optionale Zeitzone zum Ausführen\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"Montag\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"Dienstag\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"Mittwoch\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"Donnerstag\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"Freitag\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"Samstag\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"Sonntag\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"Wochen\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"Sollte null oder mehr Sekunden enthalten\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"Tage\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"Stunden\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"Minuten\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"Sekunden\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"Benachrichtigungstext und Titel sind erforderlich, wenn eine Benachrichtigungs-URL verwendet wird\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"„%s“ ist keine gültige AppRise-URL.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"RegEx „%s“ ist kein gültiger regulärer Ausdruck.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"„%s“ ist kein gültiger XPath-Ausdruck. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"„%s“ ist kein gültiger JSONPath-Ausdruck. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"„%s“ ist kein gültiger JQ-Ausdruck. (%s)\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"Leerer Wert nicht zulässig.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"Ungültiger Wert.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"Gruppe / Label\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"Überwachen\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"Prozessor\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"Bearbeiten\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"Fetch Methode\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"Benachrichtigungstext\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"Benachrichtigungsformat\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"Benachrichtigungstitel\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"Liste der Benachrichtigungs-URLs\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"Prozessor – Was möchten Sie erreichen?\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"Standardzeitzone für den Watch-Check-Planer\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"Warten Sie einige Sekunden, bevor Sie Text extrahieren.\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"Sollte eine oder mehrere Sekunden enthalten\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"Laden Sie die XLSX-Datei hoch\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"Muss eine XLSX-Datei sein!\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"Dateizuordnung\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"UI-Optionen\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"Auswahlmodus:\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"Wert\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"Prüfintervall\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"Verwenden Sie globale Einstellungen für die Zeit zwischen Prüfung und Planer.\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"CSS/xPath-Filter\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"Elemente entfernen\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"Daten extrahieren\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"Titel\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"Ignorieren Sie alle übereinstimmenden Zeilen\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"Request body\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"Request method\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"Statuscodes ignorieren (Nicht-2xx-Statuscodes wie gewohnt verarbeiten)\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"Wird nur ausgelöst, wenn eindeutige Zeilen angezeigt werden\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"Entfernen Sie doppelte Textzeilen\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"Sortieren Sie den Text alphabetisch\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"Entfernen Sie ignorierte Zeilen\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"Entfernen Sie alle Leerzeichen vor und nach jeder Textzeile\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"Hinzugefügte Zeilen\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"Zeilen ersetzt/geändert\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"Entfernte Zeilen\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"Schlüsselwort-Trigger – Auslösen/Warten auf Text\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"Blockieren Sie die Änderungserkennung, während der Text übereinstimmt\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"Führen Sie JavaScript vor der Änderungserkennung aus\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"Speichern\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"Proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"Senden Sie eine Benachrichtigung, wenn der Filter nicht mehr auf der Seite gefunden werden kann\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"Stumm\"\n\n# Context\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"An\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"Benachrichtigungen\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"Screenshot an Benachrichtigung anhängen (sofern möglich)\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"Übereinstimmung\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"Passen Sie alle folgenden Punkte an\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"Entspricht einer der folgenden Bedingungen\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"Verwenden Sie Seite <Titel> in der Liste\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"Der Textkörper muss leer sein, wenn die Anforderungsmethode auf GET gesetzt ist\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"Ungültige Vorlagensyntaxkonfiguration: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"Ungültige Vorlagensyntax: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"Ungültige Vorlagensyntax im Header „%(header)s“: %(error)s\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"Name\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"Proxy-URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"Proxy-URLs müssen mit http://, https:// oder sock5:// beginnen.\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"Browser-Verbindungs-URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"Browser-URLs müssen mit wss:// oder ws:// beginnen.\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"Klartext-Anfragen\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Chrome-Anfragen\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"Standard-Proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"Zufällige Jitter-Sekunden ± überprüfen\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"Anzahl der Fetch Arbeiter\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"Sollte zwischen 1 und 50 liegen\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"Anforderungs-Timeout in Sekunden\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"Sollte zwischen 1 und 999 liegen\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"Standardmäßige User-Agent Überschreibungen\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"Es sind sowohl ein Name als auch eine Proxy-URL erforderlich.\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"Öffnen Sie die Seite „Verlauf“ in einem neuen Tab\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"Echtzeit-UI-Updates aktiviert\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"Favicons Aktiviert\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"Verwenden Sie die Seite <Titel> in der Übersichtsliste der Beobachtungen\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"Sicherheitsüberprüfung des API-Zugriffstokens aktiviert\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"Benachrichtigungs-Basis-URL überschreiben\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"Leere Seiten als Änderung behandeln?\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"Text ignorieren\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"Leerzeichen ignorieren\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"Muss zwischen 0 und 100 liegen\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"Passwort\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"Pagergröße\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"Sollte mindestens Null sein (deaktiviert)\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"RSS-Inhaltsformat\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"RSS-<Beschreibung>-Körper erstellt aus\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"RSS-Vorlage „Systemstandard“ überschreiben\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"Passwort entfernen\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"Rendern Sie den Inhalt des Ankertags\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"Erlauben Sie anonymen Zugriff auf die Seite mit dem Wiedergabeverlauf, wenn das Passwort aktiviert ist\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"Ausgeblendete Beobachtungen aus RSS-Feed ausblenden\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"Aktivieren Sie den RSS-Reader-Modus\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"Anzahl der Änderungen, die im Beobachtungs-RSS-Feed angezeigt werden sollen\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"Sollte null oder mehr Versuche enthalten\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"Häufigkeit, mit der der Filter fehlen darf, bevor eine Benachrichtigung gesendet wird\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"RegEx zum Extrahieren\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"Als CSV exportieren\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\"Beim Durchsuchen des gesamten Überwachungsverlaufs nach diesem regulären Ausdruck wurden keine Übereinstimmungen \"\n\"gefunden.\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"Nicht genügend Daten für einen Vergleich. Mindestens 2 snapshots erforderlich.\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"Screenshots konnten nicht geladen werden: {}\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"Berechnung der Differenz fehlgeschlagen: {}\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"Der Wert des Begrenzungsrahmens ist zu lang\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"Der Begrenzungsrahmen muss folgendes Format haben: x, y, Breite, Höhe (nur Ganzzahlen)\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"Begrenzungsrahmenwerte dürfen nicht negativ sein\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"Die Werte des Begrenzungsrahmens sind zu groß\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"Der Auswahlmodus muss entweder „Element“ oder „Draw“ sein.\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"Mindeständerungsprozentsatz\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"Pixeldifferenzempfindlichkeit\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"Verwenden Sie Systemstandards\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"Begrenzungsrahmen\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"Auswahlmodus:\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"Der Wert für den Auswahlmodus ist zu lang\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"Screenshot-Vergleich\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"Vorschau nicht verfügbar – Es wurden noch keine snapshots aufgenommen.\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"Erkennung von visuellen / Bild-Screenshot-Änderungen\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"Vergleicht Screenshots mit einem schnellen OpenCV-Algorithmus, der 10- bis 100-mal schneller ist als SSIM\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"Erkennung von Bestandsauffüllung\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"Nur auf Lager (Ausverkauft -> Nur auf Lager)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"Eventuelle Änderungen der Verfügbarkeit\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"Aus, Verfügbarkeit/Auffüllung nicht verfolgen\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"Unterhalb des Preises, um eine Benachrichtigung auszulösen\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"Keine Begrenzung\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"Über dem Preis, um eine Benachrichtigung auszulösen\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"Schwellenwert in %% für Preisänderungen seit dem ursprünglichen Preis\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"Sollte zwischen 0 und 100 liegen\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"Verfolgen Sie Preisänderungen\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"Wiederauffüllung und Preiserkennung\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"Wiederauffüllung und Preiserkennung für Seiten mit einem EINZELNEN Produkt\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"Erkennt, ob das Produkt wieder auf Lager ist\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"Änderungen an Webseitentext/HTML, JSON und PDF\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"Erkennt nach Möglichkeit alle Textänderungen\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"Fehler beim Abrufen der Metadaten für {}\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"Das Protokoll wird nicht unterstützt oder das URL-Format ist ungültig.\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"Inhalt für alle Benachrichtigungen — Sie können verwenden\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"Tokens/Platzhalter anzeigen\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"Token\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"Beschreibung\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"Die UUID der Überwachung.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"Die Überwachungsgruppe / Tag\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"und\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"Mehr hier\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"AppRise-Benachrichtigungs-URLs\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"Verwenden\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"Erweiterte Hilfe und Tipps anzeigen\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"des Benachrichtigungstexts, einschließlich des Titels.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"weitere Hilfe hier\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"Testbenachrichtigung senden\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"E-Mail hinzufügen\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"E-Mail-Adresse hinzufügen\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"Benachrichtigungs-Debug-Protokolle\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"Verarbeitung läuft..\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"Titel für alle Benachrichtigungen\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"zum Beispiel -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"Format für alle Benachrichtigungen\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"Aktionen\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"Sie müssen möglicherweise\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"in der\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"Datei\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"Zeitliche Begrenzungen des Zeitplans\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"Wochenenden\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"Zurücksetzen\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"Weitere Hilfe und Beispiele zur Verwendung des Schedulers\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"Möchten Sie einen Zeitplan verwenden?\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"Auslösender Text\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"Ignorierter Text\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"Es wird keine Änderungserkennung stattfinden, da dieser Text existiert.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"Blockierter Text\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"Suchen oder Alt+S-Taste verwenden\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"Echtzeit-Updates offline\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"Wählen Sie eine Sprache aus\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"Automatisch vom Browser erkennen\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"\"\n\"Die Sprachunterstützung befindet sich in der Beta-Phase. Bitte helfen Sie uns bei der Verbesserung, indem Sie auf \"\n\"GitHub einen PR mit Updates öffnen.\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"Suchen\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"in\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"Informationen hier\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"Login\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"GRUPPEN\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"EINSTELLUNGEN\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"IMPORTIEREN\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"Benachrichtigungen entstummen\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"Benachrichtigungen stummschalten\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"Benachrichtigungen sind stummgeschaltet - klicken zum Entstummen\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"BEARBEITEN\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"ABMELDEN\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"Hell-/Dunkelmodus umschalten\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"Hell-/Dunkelmodus umschalten\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"Sprache ändern\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"Sprache ändern\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"Ja\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"Nein\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"Haupteinstellungen\"\n\n#~ msgid \"Entry\"\n#~ msgstr \"Eintrag\"\n\n#~ msgid \"Actions\"\n#~ msgstr \"Maßnahmen\"\n\n#~ msgid \"Add a row/rule after\"\n#~ msgstr \"Füge eine Zeile/Regel nach\"\n\n#~ msgid \"Remove this row/rule\"\n#~ msgstr \"Diese Zeile/Regel entfernen\"\n\n#~ msgid \"Verify this rule against current snapshot\"\n#~ msgstr \"Überprüfen Sie diese Regel anhand des aktuellen Snapshots.\"\n\n#~ msgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\n#~ msgstr \"\"\n#~ \"Fehler – Diese Überwachung benötigt Chrome (mit \"\n#~ \"Playwright/Sockpuppetbrowser), aber das Abrufen über Chrome ist nicht\"\n#~ \" aktiviert.\"\n\n#~ msgid \"Alternatively try our\"\n#~ msgstr \"Alternativ können Sie auch unser\"\n\n#~ msgid \"very affordable subscription based service which has all this setup for you\"\n#~ msgstr \"ein sehr günstiges Abonnement-basiertes Angebot, das all diese Einstellungen für Sie übernimmt\"\n\n#~ msgid \"You may need to\"\n#~ msgstr \"Das musst du\"\n\n#~ msgid \"Enable playwright environment variable\"\n#~ msgstr \"Aktivieren der Umgebungsvariable „Playwright“\"\n\n#~ msgid \"and uncomment the\"\n#~ msgstr \"und entferne den Kommentar von\"\n\n#~ msgid \"in the\"\n#~ msgstr \"Der\"\n\n#~ msgid \"file\"\n#~ msgstr \"Titel\"\n\n#~ msgid \"Set a hourly/week day schedule\"\n#~ msgstr \"Legen Sie einen Stunden-/Wochenplan fest.\"\n\n#~ msgid \"Schedule time limits\"\n#~ msgstr \"Zeitlimits festlegen\"\n\n#~ msgid \"Business hours\"\n#~ msgstr \"Geschäftszeiten\"\n\n#~ msgid \"Weekends\"\n#~ msgstr \"Wochenenden\"\n\n#~ msgid \"Reset\"\n#~ msgstr \"Zurücksetzen\"\n\n#~ msgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\n#~ msgstr \"Achtung, einer oder mehrere Ihrer „Tage“ haben eine Dauer, die sich bis in den nächsten Tag erstreckt.\"\n\n#~ msgid \"This could have unintended consequences.\"\n#~ msgstr \"Dies könnte unbeabsichtigte Folgen haben.\"\n\n#~ msgid \"More help and examples about using the scheduler\"\n#~ msgstr \"Weitere Hilfe und Beispiele finden Sie hier\"\n\n#~ msgid \"Want to use a time schedule?\"\n#~ msgstr \"Möchten Sie einen Zeitplan verwenden?\"\n\n#~ msgid \"First confirm/save your Time Zone Settings\"\n#~ msgstr \"Bestätigen/speichern Sie zunächst Ihre Einstellungen für die Zeitzone.\"\n\n#~ msgid \"Triggers a change if this text appears, AND something changed in the document.\"\n#~ msgstr \"Löst eine Änderung aus, wenn dieser Text erscheint UND sich etwas im Dokument geändert hat.\"\n\n#~ msgid \"Triggered text\"\n#~ msgstr \"Fehlertext\"\n\n#~ msgid \"Ignored for calculating changes, but still shown.\"\n#~ msgstr \"Wird für die Berechnung von Änderungen ignoriert, aber dennoch angezeigt.\"\n\n#~ msgid \"Ignored text\"\n#~ msgstr \"Fehlertext\"\n\n#~ msgid \"No change-detection will occur because this text exists.\"\n#~ msgstr \"Blockieren Sie die Änderungserkennung, während der Text übereinstimmt\"\n\n#~ msgid \"Blocked text\"\n#~ msgstr \"Fehlertext\"\n\n#~ msgid \"Search\"\n#~ msgstr \"Suchen\"\n\n#~ msgid \"URL or Title\"\n#~ msgstr \"URL oder Titel\"\n\n#~ msgid \"in\"\n#~ msgstr \"Weitere Informationen\"\n\n#~ msgid \"Enter search term...\"\n#~ msgstr \"Suchbegriff eingeben...\"\n\n#~ msgid \"Watch List\"\n#~ msgstr \"Überwachungsliste\"\n\n#~ msgid \"Watches\"\n#~ msgstr \"Überwachungen\"\n\n#~ msgid \"Queue Status\"\n#~ msgstr \"Warteschlangenstatus\"\n\n#~ msgid \"Queue\"\n#~ msgstr \"Wartend\"\n\n#~ msgid \"Sitemap Crawler\"\n#~ msgstr \"Sitemap Crawler\"\n\n#~ msgid \"Sitemap\"\n#~ msgstr \"Sitemap\"\n\n#~ msgid \"Tag unlinked removed from {} watches\"\n#~ msgstr \"Tag nicht verknüpft aus {} \\\"Watches\\\" entfernt\"\n\n#~ msgid \"All tags deleted\"\n#~ msgstr \"Alle Tags gelöscht\"\n\n#~ msgid \"Cleared snapshot history for all watches\"\n#~ msgstr \"Snapshot-Verlauf für alle Beobachtungen gelöscht\"\n\n#~ msgid \"No watches available to recheck.\"\n#~ msgstr \"Keine Überwachungen verfügbar, um erneut zu überprüfen.\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"Das Bearbeitungsformular für den Prozessor/das Plugin „{}“ kann nicht geladen werden. Fehlt das Plugin?\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"Erstellen Sie einen Link zum Teilen\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"Tipp: Sie können auch „gemeinsame“ Überwachungen hinzufügen.\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/translations/en_GB/LC_MESSAGES/messages.po",
    "content": "# British English translations for changedetection.io\n# Copyright (C) 2026 changedetection.io\n# This file is distributed under the same license as the changedetection.io project.\n# British English Translation Team, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version:  changedetection.io\\n\"\n\"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-12 16:33+0100\\n\"\n\"Last-Translator: British English Translation Team\\n\"\n\"Language: en_GB\\n\"\n\"Language-Team: British English\\n\"\n\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"\"\n\n#~ msgid \"Tag deleted and removed from {} watches\"\n#~ msgstr \"\"\n\n#~ msgid \"Tag unlinked removed from {} watches\"\n#~ msgstr \"\"\n\n#~ msgid \"All tags deleted\"\n#~ msgstr \"\"\n\n#~ msgid \"Cleared snapshot history for all watches\"\n#~ msgstr \"\"\n\n#~ msgid \"No watches available to recheck.\"\n#~ msgstr \"\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/translations/en_US/LC_MESSAGES/messages.po",
    "content": "# English (United States) translations for changedetection.io.\n# Copyright (C) 2026 changedetection.io\n# This file is distributed under the same license as the changedetection.io project.\n# American English Translation Team, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PROJECT VERSION\\n\"\n\"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-12 16:37+0100\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language: en_US\\n\"\n\"Language-Team: American English\\n\"\n\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"Add a new organizational tag\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"No website organizational tags/groups configured\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"organizational tag/group name used in the main listing page\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"\"\n\n#~ msgid \"Tag deleted and removed from {} watches\"\n#~ msgstr \"\"\n\n#~ msgid \"Tag unlinked removed from {} watches\"\n#~ msgstr \"\"\n\n#~ msgid \"All tags deleted\"\n#~ msgstr \"\"\n\n#~ msgid \"Cleared snapshot history for all watches\"\n#~ msgstr \"\"\n\n#~ msgid \"No watches available to recheck.\"\n#~ msgstr \"\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/translations/es/LC_MESSAGES/messages.po",
    "content": "#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: changedetection.io 0.53.6\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-03-08 19:58+0100\\n\"\n\"Last-Translator: Adrian Gonzalez <adrian@example.com>\\n\"\n\"Language-Team: Español\\n\"\n\"Language: es\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"\"\n\"Ya se está ejecutando una copia de seguridad. Vuelve a comprobarla en unos \"\n\"minutos.\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"Se alcanzó el número máximo de copias de seguridad; elimine algunas\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"\"\n\"La copia de seguridad se está creando en segundo plano. Vuelve a comprobarlo\"\n\" en unos minutos.\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"Se eliminaron las copias de seguridad.\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"Archivo zip de copia de seguridad\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"¡Debe ser un archivo de copia de seguridad .zip!\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"Incluir grupos\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"Reemplazar grupos existentes del mismo UUID\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"Incluir monitores\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"Reemplazar monitores existentes del mismo UUID\"\n\n#: changedetectionio/blueprint/backups/restore.py\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"Restaurar copia de seguridad\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\"Ya se está ejecutando una restauración, vuelve a comprobarlo en unos \"\n\"minutos.\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"No se ha subido ningún archivo\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"El archivo debe ser un archivo de copia de seguridad .zip\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"Archivo zip no válido o dañado\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\"La restauración comenzó en segundo plano, vuelve a comprobarlo en unos \"\n\"minutos.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"Crear\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"Restaurar\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"¡Se está ejecutando una copia de seguridad!\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"\"\n\"Here you can download and request a new backup, when a backup is completed \"\n\"you will see it listed below.\"\nmsgstr \"\"\n\"Aquí puede descargar y solicitar una nueva copia de seguridad; cuando se \"\n\"complete una copia de seguridad, la verá en la lista a continuación.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"Mb\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"No se encontraron copias de seguridad.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"Crear copia de seguridad\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"Eliminar copias de seguridad\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"¡Se está ejecutando una restauración!\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"\"\n\"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new \"\n\"database layout).\"\nmsgstr \"\"\n\"Restaurar una copia de seguridad. Debe ser un archivo de copia de seguridad \"\n\".zip creado a partir de la versión 0.53.1 (nuevo diseño de base de datos).\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"\"\n\"Note: This does not override the main application settings, only watches and\"\n\" groups.\"\nmsgstr \"\"\n\"Nota: Esto no sobrescribe la configuración principal de la aplicación, solo \"\n\"monitores y grupos.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"¿Incluir todos los grupos encontrados en la copia de seguridad?\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"¿Reemplazar cualquier grupo existente del mismo UUID?\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"¿Incluir todos los monitores encontrados en la copia de seguridad?\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"¿Reemplazar cualquier monitor existente con el mismo UUID?\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"\"\n\"Importing 5,000 of the first URLs from your list, the rest can be imported \"\n\"again.\"\nmsgstr \"\"\n\"Importando 5.000 de las primeras URL de tu lista, el resto se puede importar\"\n\" nuevamente.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"{} importado de la lista en {:.2f}s, {} omitido.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"No se puede leer el archivo JSON, ¿estaba roto?\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"La estructura JSON parece no válida, ¿estaba rota?\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"{} importado de Distill.io en {:.2f}s, {} omitido.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"\"\n\"No se puede leer el archivo XLSX exportado, ¿hay algún problema con el \"\n\"archivo?\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"\"\n\"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"\"\n\"Error al procesar la fila número {}, el valor de la URL era incorrecto y se \"\n\"omitió la fila.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"\"\n\"Error processing row number {}, check all cell data types are correct, row \"\n\"was skipped.\"\nmsgstr \"\"\n\"Error al procesar la fila número {}, compruebe que todos los tipos de datos \"\n\"de celda sean correctos; se omitió la fila.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"{} importado de Wachete .xlsx en {:.2f}s\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"{} importado desde .xlsx personalizado en {:.2f}s\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"Lista de URL\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX y Wachete\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\"Restaurar las copias de seguridad de changetection.io se encuentra en el\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"sección de copias de seguridad\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"\"\n\"Enter one URL per line, and optionally add tags for each URL after a space, \"\n\"delineated by comma (,):\"\nmsgstr \"\"\n\"Ingrese una URL por línea y, opcionalmente, agregue etiquetas para cada URL \"\n\"después de un espacio, delimitado por una coma (,):\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"Ejemplo:\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"Las URL que no pasen la validación permanecerán en el área de texto.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"\"\n\"Copy and Paste your Distill.io watch 'export' file, this should be a JSON \"\n\"file.\"\nmsgstr \"\"\n\"Copie y pegue el archivo de 'exportación' del monitor Distill.io, este \"\n\"debería ser un archivo JSON.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"Esto es\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"experimental\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"Los campos admitidos son\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"el resto (incluyendo\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"son ignorados.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"¿Cómo exportar?\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"\"\n\"Asegúrese de configurar su buscador predeterminado en Chrome si es \"\n\"necesario.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"\"\n\"Tabla de asignación de tipos de datos y columnas personalizadas para el\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"Mapeo personalizado\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"Tipo de asignación de archivos.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"Columna #\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"Tipo\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"ninguno\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"Filtro CSS/xPath\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"Nombre(s) de grupo/etiqueta\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"Tiempo de revisión (minutos)\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"Importar\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"Ver con UUID%(uuid)sextraviado\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"\"\n\"Watch %(uuid)s does not have enough history snapshots to show changes (need \"\n\"at least 2)\"\nmsgstr \"\"\n\"Mirar%(uuid)sno tiene suficientes instantáneas del historial para mostrar \"\n\"los cambios (se necesitan al menos 2)\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"Se eliminó la protección con contraseña.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"\"\n\"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"\"\n\"Advertencia: recuento de trabajadores ({} ) está cerca o excede los núcleos \"\n\"de CPU disponibles ({} )\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"Conteo de trabajadores ajustado:{}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"\"\n\"El ajuste dinámico de trabajadores no es compatible con trabajadores \"\n\"sincronizados\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"Error al ajustar trabajadores:{}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"Protección con contraseña habilitada.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"Configuración actualizada.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#: changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"Se produjo un error, consulte a continuación.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"La clave API se regeneró.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"Programación automática en pausa: las comprobaciones no se pondrán en cola.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"Se reanudó la programación automática: las comprobaciones se pondrán en cola normalmente.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"Todas las notificaciones silenciadas.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"Todas las notificaciones activadas.\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"Registro de depuración de notificaciones\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"General\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"Atractivo\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"Filtros globales\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"Opciones de interfaz de usuario\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"Copias de seguridad\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"Hora y fecha\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"CAPTCHA y servidores proxy\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"Información\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"\"\n\"Tiempo de revisión predeterminado para todos los monitores, el mínimo actual\"\n\" del sistema es\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"más información\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"After this many consecutive times that the CSS/xPath filter is missing, send\"\n\" a notification\"\nmsgstr \"\"\n\"Después de tantas veces consecutivas que falta el filtro CSS/xPath, envíe \"\n\"una notificación\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"Empezar a\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"deshabilitar\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Limit collection of history snapshots for each watch to this number of \"\n\"history items.\"\nmsgstr \"\"\n\"Limite la recopilación de instantáneas del historial para cada monitor a \"\n\"esta cantidad de elementos del historial.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"Establecer en vacío para deshabilitar/sin límite\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"Protección con contraseña para su aplicación changetection.io.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"La contraseña está bloqueada.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Allow access to the watch change history page when password is enabled (Good\"\n\" for sharing the diff page)\"\nmsgstr \"\"\n\"Permitir el acceso a la página del historial de cambios del monitor cuando \"\n\"la contraseña está habilitada (bueno para compartir la página de \"\n\"diferencias)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"When a request returns no content, or the HTML does not contain any text, is\"\n\" this considered a change?\"\nmsgstr \"\"\n\"Cuando una solicitud no devuelve contenido o el HTML no contiene ningún \"\n\"texto, ¿se considera esto un cambio?\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"Elija un proxy predeterminado para todos los monitores\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"URL base utilizada para el\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"token en enlaces de notificación.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"El valor predeterminado es la variable de entorno del sistema.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"leer más aquí\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"\"\n\"método (predeterminado) donde los sitios observados no necesitan Javascript \"\n\"para renderizarse.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"Utilice el\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"Básico\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"method requires a network connection to a running WebDriver+Chrome server, \"\n\"set by the ENV var\"\nmsgstr \"\"\n\"El método requiere una conexión de red a un servidor WebDriver+Chrome en \"\n\"ejecución, establecido por la var ENV\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"El\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"Cromo/Javascript\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text \"\n\"missing etc), try increasing the 'wait' time here.\"\nmsgstr \"\"\n\"Si tiene problemas para esperar a que la página se represente por completo \"\n\"(falta texto, etc.), intente aumentar el tiempo de \\\"espera\\\" aquí.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"Esto esperará\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"segundos antes de extraer el texto.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Number of concurrent workers to process watches. More workers = faster \"\n\"processing but higher memory usage.\"\nmsgstr \"\"\n\"Número de trabajadores simultáneos para procesar monitores. Más trabajadores\"\n\" = procesamiento más rápido pero mayor uso de memoria.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"Actualmente en funcionamiento:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"operacional\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"trabajadores\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"procesando activamente\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or \"\n\"up to 3 seconds later\"\nmsgstr \"\"\n\"Ejemplo: una fluctuación aleatoria de 3 segundos podría activarse hasta 3 \"\n\"segundos antes o hasta 3 segundos después\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"For regular plain requests (not chrome based), maximum number of seconds \"\n\"until timeout, 1-999.\"\nmsgstr \"\"\n\"Para solicitudes simples regulares (no basadas en Chrome), número máximo de \"\n\"segundos hasta el tiempo de espera, 1-999.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"Aplicado a todas las solicitudes.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Note: Simply changing the User-Agent often does not defeat anti-robot \"\n\"technologies, it's important to consider\"\nmsgstr \"\"\n\"Nota: El simple hecho de cambiar el User-Agent a menudo no derrota a las \"\n\"tecnologías anti-robots; es importante tenerlo en cuenta.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"todas las formas en que se detecta el navegador\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"Consejo:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"\"\n\"Conéctese utilizando Bright Data y Oxylabs Proxies; obtenga más información \"\n\"aquí.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Ignore whitespace, tabs and new-lines/line-feeds when considering if a \"\n\"change was detected.\"\nmsgstr \"\"\n\"Ignore los espacios en blanco, las tabulaciones y las nuevas líneas/avances \"\n\"de línea al considerar si se detectó un cambio.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"Nota:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Changing this will change the status of your existing watches, possibly \"\n\"trigger alerts etc.\"\nmsgstr \"\"\n\"Cambiar esto cambiará el estado de sus monitores existentes, posiblemente \"\n\"activará alertas, etc.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\"Representar el contenido de la etiqueta de anclaje, deshabilitado de forma \"\n\"predeterminada; cuando está habilitado, los enlaces se muestran como\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Changing this could affect the content of your existing watches, possibly \"\n\"trigger alerts etc.\"\nmsgstr \"\"\n\"Cambiar esto podría afectar el contenido de sus monitores existentes, \"\n\"posiblemente activar alertas, etc.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\"Elimine los elementos HTML mediante los selectores CSS y XPath antes de la \"\n\"conversión de texto.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"No pegue HTML aquí, use solo selectores CSS y XPath\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Add multiple elements, CSS or XPath selectors per line to ignore multiple \"\n\"parts of the HTML.\"\nmsgstr \"\"\n\"Agregue múltiples elementos, selectores CSS o XPath por línea para ignorar \"\n\"múltiples partes del HTML.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\"Nota: Esto se aplica globalmente además de las reglas por visualización.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"El texto coincidente será\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"ignorado\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\"en la instantánea de texto (aún puedes verla pero no activará un cambio)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Each line processed separately, any line matching will be ignored (removed \"\n\"before creating the checksum)\"\nmsgstr \"\"\n\"Cada línea se procesa por separado, cualquier coincidencia de líneas se \"\n\"ignorará (se eliminará antes de crear la suma de verificación)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\"Soporte de expresión regular, ajuste toda la línea con una barra diagonal\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\"Cambiar esto afectará la suma de verificación de comparación, lo que puede \"\n\"generar una alerta.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Remove any text that appears in the \\\"Ignore text\\\" from the output \"\n\"(otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\"Elimine cualquier texto que aparezca en \\\"Ignorar texto\\\" de la salida (de \"\n\"lo contrario, simplemente se ignorará para la detección de cambios)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"Acceso API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"Conduzca su changetection.io a través de API, más información\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"Acceso API y ejemplos aquí\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"Restringir el límite de acceso a la API mediante el uso\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"encabezado: necesario para que funcione la extensión de Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"Clave API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"Copiar\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"Regenerar clave API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Extensión de Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Easily add any web-page to your changedetection.io installation from within \"\n\"Chrome.\"\nmsgstr \"\"\n\"Agregue fácilmente cualquier página web a su instalación de changetection.io\"\n\" desde Chrome.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"Paso 1\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"Instale la extensión,\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"Paso 2\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"Navega a esta página,\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"Paso 3\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"Abra la extensión desde la barra de herramientas y haga clic\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"Acceso a la API de sincronización\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"¡Pruebe nuestra nueva extensión de Chrome!\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"Icono de la tienda de Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Tienda web de Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Maximum number of history snapshots to include in the watch specific RSS \"\n\"feed.\"\nmsgstr \"\"\n\"Número máximo de instantáneas del historial que se incluirán en la fuente \"\n\"RSS específica del monitor.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"For watching other RSS feeds - When watching RSS/Atom feeds, convert them \"\n\"into clean text for better change detection.\"\nmsgstr \"\"\n\"Para ver otras fuentes RSS: cuando vea fuentes RSS/Atom, conviértalas en \"\n\"texto limpio para una mejor detección de cambios.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"¿Su lector soporta HTML? Ponlo aquí\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"'System default' for the same template for all items, or re-use your \"\n\"\\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\"'Predeterminado del sistema' para la misma plantilla para todos los \"\n\"elementos, o reutilice su \\\"Cuerpo de notificación\\\" como plantilla.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Ensure the settings below are correct, they are used to manage the time \"\n\"schedule for checking your web page watches.\"\nmsgstr \"\"\n\"Asegúrese de que las configuraciones a continuación sean correctas, se \"\n\"utilizan para administrar el horario para verificar las visitas a su página \"\n\"web.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"Hora y fecha UTC del servidor:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"Hora y fecha locales en el navegador:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"Enable this setting to open the diff page in a new tab. If disabled, the \"\n\"diff page will open in the current tab.\"\nmsgstr \"\"\n\"Habilite esta configuración para abrir la página de diferencias en una nueva\"\n\" pestaña. Si está deshabilitado, la página de diferencias se abrirá en la \"\n\"pestaña actual.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\"Actualizaciones de UI en tiempo real habilitadas: (es necesario reiniciar si\"\n\" se cambia esto)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"Habilitar o deshabilitar favicons junto a la lista de monitores\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"Número de elementos por página en la lista general de monitores, 0 para desactivar.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"Consejo\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \"\n\"\\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\"El tipo de proxy \\\"residencial\\\" y \\\"móvil\\\" puede tener más éxito que el \"\n\"\\\"centro de datos\\\" para sitios web bloqueados.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\"\\\"Nombre\\\" se utilizará para seleccionar el proxy en la configuración de \"\n\"edición de visualización.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' \"\n\"fetcher, for other fetchers you should whitelist the IP access instead\"\nmsgstr \"\"\n\"Los proxies SOCKS5 con autenticación solo son compatibles con el buscador de\"\n\" 'solicitudes simples'; para otros buscadores, en su lugar, debe incluir en \"\n\"la lista blanca el acceso IP\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"Tiempo de actividad:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"Versión de Python:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"Complementos activos:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"No hay complementos activos\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"Atrás\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"Borrar historial de instantáneas\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"La etiqueta \\\"{} \\\"ya existe\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"Etiqueta agregada\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"Etiqueta eliminada, eliminada de monitores en segundo plano\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"Desvincular etiqueta de monitores en segundo plano\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\"Todas las etiquetas eliminadas, borradas de los monitores en segundo plano.\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"Etiqueta no encontrada\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"Actualizado\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"Filtros y activadores\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"Estas configuraciones son\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"agregado\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"a cualquier configuración de monitor existente.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"Filtrado de texto\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"¡Úselo con precaución!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"\"\n\"Esto llenará fácilmente su cuota de almacenamiento de correo electrónico o \"\n\"inundará otros almacenamientos.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"¡Estar atento!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"¡Estar atento!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"Hay\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"URL de notificación en todo el sistema habilitadas\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"\"\n\"este formulario anulará la configuración de notificaciones solo para este \"\n\"monitor\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"\"\n\"una lista de URL de notificación vacía aquí seguirá enviando notificaciones.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"Usar los valores predeterminados del sistema\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"Agregar una nueva etiqueta organizacional\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"Ver grupo/etiqueta\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"\"\n\"Groups allows you to manage filters and notifications for multiple watches \"\n\"under a single organisational tag.\"\nmsgstr \"\"\n\"Grupos le permite administrar filtros y notificaciones para múltiples \"\n\"monitores bajo una única etiqueta organizacional.\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"# monitores\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"Nombre de etiqueta/etiqueta\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"No hay etiquetas/grupos organizativos del sitio web configurados\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"Editar\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"Vuelva a comprobar\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"¿Eliminar grupo?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to delete group \"\n\"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\"<p>¿Estás seguro de que deseas eliminar el \"\n\"grupo?<strong>%(title)s</strong>?</p><p>Esta acción no se puede \"\n\"deshacer.</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"Borrar\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"Elimina y elimina etiqueta\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"¿Desvincular grupo?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group \"\n\"<strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be \"\n\"removed from it.</p>\"\nmsgstr \"\"\n\"<p>¿Está seguro de que desea desvincular todos los monitores del \"\n\"grupo?<strong>%(title)s</strong>?</p><p>La etiqueta se mantendrá pero se le \"\n\"quitarán los monitores.</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"Desconectar\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"Conserva la etiqueta pero desvincula cualquier monitor\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"Feed RSS para este monitor\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches deleted\"\nmsgstr \"{} monitores eliminados\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches paused\"\nmsgstr \"{} monitores en pausa\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches unpaused\"\nmsgstr \"{} monitores sin pausa\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches updated\"\nmsgstr \"{} monitores actualizados\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches muted\"\nmsgstr \"{} monitores silenciados\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches un-muted\"\nmsgstr \"{} monitores sin silenciar\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"{} monitores en cola para volver a revisarse\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches errors cleared\"\nmsgstr \"{} errores de monitores borrados\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"{} monitores borrados/restablecidos.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"\"\n\"{} monitores configurados para usar la configuración de notificación \"\n\"predeterminada\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches were tagged\"\nmsgstr \"Se etiquetaron {} monitores\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"Monitor no encontrado\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"Se borró el historial de instantáneas del monitor {}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"La limpieza del historial comenzó en segundo plano.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"Texto de confirmación incorrecto.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"El monitor por UUID{} no existe.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"Eliminado.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"Clonado, estás editando el nuevo monitor.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"El monitor ya está en cola o en proceso de verificación.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"1 monitor en cola para volver a verificar.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"{} monitores en cola para volver a comprobar ({} ya en cola o en ejecución).\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"{} monitores en cola para volver a comprobar.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"Poniendo monitores en cola para volver a comprobar en segundo plano...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"\"\n\"Could not share, something went wrong while communicating with the share \"\n\"server - {}\"\nmsgstr \"\"\n\"No se pudo compartir, algo salió mal al comunicarse con el servidor \"\n\"compartido.{}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"Idioma configurado para detectarse automáticamente desde el navegador\"\n\n#: changedetectionio/blueprint/ui/diff.py\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"\"\n\"No se encontró historial para el enlace especificado, ¿enlace incorrecto?\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"\"\n\"Not enough history (2 snapshots required) to show difference page for this \"\n\"watch.\"\nmsgstr \"\"\n\"No hay suficiente historial (se requieren 2 instantáneas) para mostrar la \"\n\"página de diferencias para este monitor.\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"No hay monitores para editar\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"No se encontró ningún monitor con el UUID {}.\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Switched to mode - {}.\"\nmsgstr \"Cambiado al modo: {}.\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"\"\n\"Could not load '{}' processor, processor plugin might be missing. Please \"\n\"select a different processor.\"\nmsgstr \"No se pudo cargar el procesador '{}'; es posible que falte el complemento del procesador. Seleccione un procesador diferente.\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"No se pudo cargar el procesador '{}'; es posible que falte el complemento del procesador.\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"Monitor actualizado: ¡sin pausa!\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"Monitor actualizado.\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"\"\n\"Vista previa no disponible: no se completó la búsqueda/verificación o no se \"\n\"alcanzaron los activadores\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"\"\n\"This will remove version history (snapshots) for ALL watches, but keep your \"\n\"list of URLs!\"\nmsgstr \"\"\n\"Esto eliminará el historial de versiones (instantáneas) de TODOS los \"\n\"monitores, ¡pero mantendrá su lista de URL!\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"Es posible que desee utilizar el\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"RESPALDO\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"enlace primero.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"Texto de confirmación\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"Escribe la palabra\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"claro\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"para confirmar que entiende.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"¡Borrar historial!\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\n#: changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"Cancelar\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"Compartir diferencias como imagen\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"Compartir como imagen\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"Ignora cualquier línea que coincida\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"Ignore cualquier línea que coincida excluyendo dígitos\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"De\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"A\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"Palabras\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"Pauta\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"Ignorar espacios en blanco\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"Igual/sin cambios\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"Remoto\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"Agregado\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"Reemplazado\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"Teclado:\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"Anterior\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"Próximo\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"Saltar a la siguiente diferencia\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"Saltar\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"Texto de error\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"Captura de pantalla de error\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"Texto\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"Captura de pantalla actual\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"Extraer datos\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"Hace segundos.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"hace segundos\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"Captura de pantalla con error actual de la solicitud más reciente\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"Consejo profesional: puedes habilitar\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\\\"compartir acceso cuando la contraseña está habilitada\\\"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"desde la configuración.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"Ir a instantánea única\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"Resalte el texto para compartir o agregar a las listas de ignorados.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"\"\n\"For now, Differences are performed on text, not graphically, only the latest\"\n\" screenshot is available.\"\nmsgstr \"\"\n\"Por ahora, las diferencias se realizan en texto, no gráficamente, solo está \"\n\"disponible la última captura de pantalla.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"Captura de pantalla actual de la solicitud más reciente\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"\"\n\"¡Aún no hay captura de pantalla disponible! Intente volver a revisar la \"\n\"página.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"\"\n\"La captura de pantalla requiere que Playwright/WebDriver esté habilitado\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"Pedido\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"Pasos del navegador\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"Selector de filtro visual\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"Condiciones\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"Estadísticas\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"\"\n\"Algunos sitios utilizan JavaScript para crear el contenido, para ello debes\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"utilizar el buscador de Chrome/WebDriver\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"Las variables son compatibles con la URL.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"ayuda y ejemplos aquí\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"\"\n\"Etiqueta organizativa/nombre de grupo utilizado en la página principal del \"\n\"listado\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Automatically uses the page title if found, you can also use your own \"\n\"title/description here\"\nmsgstr \"\"\n\"Utiliza automáticamente el título de la página si la encuentra, también \"\n\"puede usar su propio título/descripción aquí\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"El intervalo/cantidad de tiempo entre cada verificación.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good\"\n\" for knowing when the page changed and your filter will not work anymore.\"\nmsgstr \"\"\n\"Envía una notificación cuando el filtro ya no se puede ver en la página, lo \"\n\"cual es bueno para saber cuándo cambió la página y su filtro ya no \"\n\"funcionará.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\"Establecer en vacío para usar la configuración predeterminada del sistema\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"\"\n\"método (predeterminado) donde su sitio observado no necesita Javascript para\"\n\" renderizarse.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"method requires a network connection to a running WebDriver+Chrome server, \"\n\"set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\"El método requiere una conexión de red a un servidor WebDriver+Chrome en \"\n\"ejecución, establecido por la variable ENV 'WEBDRIVER_URL'.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"Verificar/Escanear todo\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"Elija un proxy para este monitor\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"Uso de la configuración predeterminada global actual\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"Mostrar opciones avanzadas\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Run this code before performing change detection, handy for filling in \"\n\"fields and other actions\"\nmsgstr \"\"\n\"Ejecute este código antes de realizar la detección de cambios, útil para \"\n\"completar campos y otras acciones.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"Más ayuda y ejemplos aquí.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"Las variables se admiten en el cuerpo de la solicitud.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"\"\n\"Las variables se admiten en los valores del encabezado de la solicitud.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"\"\n\"¡Alerta! ¡Se encontró un archivo de encabezados adicional que se agregará a \"\n\"este monitor!\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"\"\n\"Los encabezados también se pueden leer desde un archivo en su directorio de \"\n\"datos\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"Leer más aquí\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"No es compatible con el navegador Selenium\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"Activar el buscador de texto\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"\"\n\"Espere, el primer paso del navegador puede tardar un poco en cargarse.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"Haga clic aquí para comenzar\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"Espere entre 10 y 15 segundos para que se conecte el navegador.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"Presione \\\"Reproducir\\\" para comenzar.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"\"\n\"Los datos del selector visual no están listos; es necesario revisar el \"\n\"monitor al menos una vez.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive \"\n\"Javascript (so far only Playwright based fetchers)\"\nmsgstr \"\"\n\"Lo sentimos, esta funcionalidad solo funciona con buscadores que admiten \"\n\"Javascript interactivo (hasta ahora solo buscadores basados ​​en Playwright)\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"a uno que admita Javascript interactivo.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"Necesitas\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"Establecer el método de recuperación\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Use the verify (✓) button to test if a condition passes against the current \"\n\"snapshot.\"\nmsgstr \"\"\n\"Utilice el botón de verificación ( ✓ ) para probar si una condición se \"\n\"cumple con la instantánea actual.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"Lea un tutorial rápido sobre\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"usando cambios de página web condicionales aquí\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"Activar vista previa\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"Consejos profesionales:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"\"\n\"Utilice la página de vista previa para ver sus filtros y activadores \"\n\"resaltados.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"Limitar activación/ignorar/bloquear/extraer a;\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Note: Depending on the length and similarity of the text on each line, the \"\n\"algorithm may consider an\"\nmsgstr \"\"\n\"Nota: Dependiendo de la extensión y similitud del texto en cada línea, el \"\n\"algoritmo puede considerar una\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"en lugar de\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"reemplazo\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"Por ejemplo.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"suma\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"Por eso siempre es mejor seleccionar\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"cuando esté interesado en contenido nuevo.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"\"\n\"Cuando el contenido simplemente se mueve en una lista, también activará una\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"considere habilitar\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"Solo se activa cuando aparecen líneas únicas\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know \"\n\"when NEW content is added, compares new lines against all history for this \"\n\"watch.\"\nmsgstr \"\"\n\"Bueno para sitios web que simplemente mueven el contenido y desea saber \"\n\"cuándo se agrega contenido NUEVO, compara nuevas líneas con todo el \"\n\"historial de este monitor.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Helps reduce changes detected caused by sites shuffling lines around, \"\n\"combine with\"\nmsgstr \"\"\n\"Ayuda a reducir los cambios detectados causados ​​por sitios que cambian las\"\n\" líneas, combinar con\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"comprobar líneas únicas\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"abajo.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"\"\n\"Elimine cualquier espacio en blanco antes y después de cada línea de texto.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"Cargando...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"La herramienta Selector visual le permite seleccionar el\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"texto\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-\"\n\"in the filters in the \\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\"elementos que se utilizarán para la detección de cambios. Completa \"\n\"automáticamente los filtros en el cuadro \\\"Filtros CSS/JSONPath/JQ/XPath\\\" \"\n\"del\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"pestaña. Usar\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"Mayús+clic\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"para seleccionar varios elementos.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"Modo de selección:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"Seleccionar por elemento\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"Área de dibujo\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"Borrar selección\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"\"\n\"Un momento, obteniendo captura de pantalla e información del elemento.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"Actualmente:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support Javascript \"\n\"and screenshots (such as playwright etc).\"\nmsgstr \"Lo sentimos, esta funcionalidad solo funciona con métodos de obtención que admiten JavaScript y capturas de pantalla (como Playwright, etc.).\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"a uno que admita Javascript y capturas de pantalla.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"Número de comprobaciones\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"Fallos consecutivos del filtro\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"Longitud del historial\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"Duración de la última recuperación\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"Recuento de alertas de notificación\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"Respuesta de tipo de servidor\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"Descargar la última instantánea HTML\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"Descargar el paquete de datos del monitor\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"¿Eliminar monitor?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"¿Estás seguro de que deseas eliminar el monitor de:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"Esta acción no se puede deshacer.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"¿Borrar historial?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"¿Está seguro de que desea borrar todo el historial de:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"This will remove all snapshots and previous versions. This action cannot be \"\n\"undone.\"\nmsgstr \"\"\n\"Esto eliminará todas las instantáneas y versiones anteriores. Esta acción no\"\n\" se puede deshacer.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"Borrar historial\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"Clonar y editar\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"Seleccionar marca de tiempo\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"Ir\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"Captura de pantalla con error actual de la solicitud más reciente\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"\"\n\"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) \"\n\"that supports screenshots.\"\nmsgstr \"\"\n\"La captura de pantalla requiere un buscador de contenido (Sockpuppetbrowser,\"\n\" selenium, etc.) que admita capturas de pantalla.\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"Advertencia, URL{} ya existe\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"Monitor agregado en estado de pausa, el guardado se reanudará.\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"Monitor añadido.\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"\"\n\"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"mostrando<b>{start} - {end}</b> {record_name}en total<b>{total}</b>\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"archivos\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"\"\n\"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\"¡Changedetection.io puede monitorear más que solo páginas web! ¡Vea nuestros\"\n\" complementos!\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"Más información\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"También puedes agregar monitores 'compartidos'.\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"Agregar un nuevo monitor de detección de cambios en páginas web\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"¡Monitoriza esta URL!\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"Editar primero y luego monitorizar\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"Pausa\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"Reanudar la pausa\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"Silenciar\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"Dejar de silenciar\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"Etiqueta\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"Marcar visto\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"Usar notificación predeterminada\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"Borrar errores\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"Borrar historiales\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"\"\n\"<p>Are you sure you want to clear history for the selected items?</p><p>This\"\n\" action cannot be undone.</p>\"\nmsgstr \"\"\n\"<p>¿Está seguro de que desea borrar el historial de los elementos \"\n\"seleccionados?</p><p>Esta acción no se puede deshacer.</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"DE ACUERDO\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"Borrar/restablecer historial\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"¿Eliminar monitores?\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"\"\n\"<p>Are you sure you want to delete the selected watches?</strong></p><p>This\"\n\" action cannot be undone.</p>\"\nmsgstr \"\"\n\"<p>¿Está seguro de que desea eliminar los monitores \"\n\"seleccionados?</strong></p><p>Esta acción no se puede deshacer.</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"Tamaño de la cola\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"Búsqueda\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"Todo\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"Sitio web\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"Reabastecimiento y precio\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"Comprobado\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"Último\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"Cambió\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"\"\n\"No web page change detection watches configured, please add a URL in the box\"\n\" above, or\"\nmsgstr \"\"\n\"No hay monitores de detección de cambios de página web configuradas; agregue\"\n\" una URL en el cuadro de arriba, o\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"importar una lista\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"Detección de reabastecimiento y precio\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"En stock\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"No en stock\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"Precio\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"Sin información\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#: changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"Comprobando ahora\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"En cola\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"Historia\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"Avance\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"Con errores\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"Marcar todo visto\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"Marcar todo visto en '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"No leído\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"Vuelva a comprobar todo\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"en '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#: changedetectionio/flask_app.py changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"Aún no\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"0 segundos\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"año\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"años\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"mes\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"meses\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"semana\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"semanas\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"día\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"días\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"hora\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"horas\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"minuto\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"minutos\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"segundo\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"artículos de segunda clase\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"Ya has iniciado sesión\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"Debes iniciar sesión, por favor inicia sesión.\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"Contraseña incorrecta\"\n\n#: changedetectionio/forms.py\nmsgid \"\"\n\"At least one time interval (weeks, days, hours, minutes, or seconds) must be\"\n\" specified.\"\nmsgstr \"\"\n\"Se debe especificar al menos un intervalo de tiempo (semanas, días, horas, \"\n\"minutos o segundos).\"\n\n#: changedetectionio/forms.py\nmsgid \"\"\n\"At least one time interval (weeks, days, hours, minutes, or seconds) must be\"\n\" specified when not using global settings.\"\nmsgstr \"\"\n\"Se debe especificar al menos un intervalo de tiempo (semanas, días, horas, \"\n\"minutos o segundos) cuando no se utilice la configuración global.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"Formato de hora no válido. Utilice HH:MM.\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"No es un nombre de zona horaria válido\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"no establecido\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"Empezar en\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"Duración de la ejecución\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"Usar programador de tiempo\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"Zona horaria opcional para ejecutar\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"Lunes\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"Martes\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"Miércoles\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"Jueves\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"Viernes\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"Sábado\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"Domingo\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"Semanas\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"Debe contener cero o más segundos.\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"Días\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"Horas\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"Minutos\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"Artículos de segunda clase\"\n\n#: changedetectionio/forms.py\nmsgid \"\"\n\"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"\"\n\"Se requiere el cuerpo y el título de la notificación cuando se utiliza una \"\n\"URL de notificación\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"'%s' no es una URL de AppRise válida.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"Expresión regular '%s' no es una expresión regular válida.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"'%s' no es una expresión XPath válida. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"'%s' no es una expresión JSONPath válida. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"'%s' no es una expresión jq válida. (%s)\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"Valor vacío no permitido.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"Valor no válido.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\n#: changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"Etiqueta de grupo\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"Monitor\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"Procesador\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"Editar > Ver\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"Método de recuperación\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"Organismo de notificación\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"Formato de notificación\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"Título de la notificación\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"Lista de URL de notificación\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"Procesador: ¿Qué quieres lograr?\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"Zona horaria predeterminada para el programador de verificación de monitores\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"Espere unos segundos antes de extraer el texto.\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"Debe contener uno o más segundos.\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"Subir archivo .xlsx\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"¡Debe ser un archivo .xlsx!\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"Mapeo de archivos\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"Operación\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"Selector\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"valor\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"Tiempo entre comprobaciones\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"\"\n\"Utilice la configuración global para el tiempo entre la verificación y el \"\n\"programador.\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"Filtros CSS/JSONPath/JQ/XPath\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"Eliminar elementos\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"Extraer texto\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\n#: changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"Título\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"Ignora las líneas que contienen\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"Cuerpo de la solicitud\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"Método de solicitud\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"\"\n\"Ignorar códigos de estado (procesar códigos de estado que no sean 2xx como \"\n\"de costumbre)\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"Solo se activa cuando aparecen líneas únicas en todo el historial\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"Eliminar líneas de texto duplicadas\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"Ordenar texto alfabéticamente\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"Eliminar líneas ignoradas\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"Recortar espacios en blanco antes y después del texto\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"Líneas agregadas\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"Líneas reemplazadas/cambiadas\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"Líneas eliminadas\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"Activadores de palabras clave: activar/esperar texto\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"Bloquear la detección de cambios mientras el texto coincide\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"Ejecute JavaScript antes de la detección de cambios\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"Ahorrar\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"Apoderado\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"\"\n\"Enviar una notificación cuando el filtro ya no se pueda encontrar en la \"\n\"página\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"Apagado\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"En\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"Notificaciones\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"Adjunte captura de pantalla a la notificación (cuando sea posible)\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"Fósforo\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"Coincide con todo lo siguiente\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"Coincide con cualquiera de los siguientes\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"Usar página<title>en la lista\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"Número de elementos del historial por monitor que se deben conservar\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"\"\n\"El cuerpo debe estar vacío cuando el método de solicitud está configurado en\"\n\" GET\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"Configuración de sintaxis de plantilla no válida:%(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"Sintaxis de plantilla no válida:%(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"Sintaxis de plantilla no válida en \\\"%(header)s\\\"encabezado:%(error)s\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"Nombre\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"URL de proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"\"\n\"Las URL de proxy deben comenzar con http://, https:// o calcetines5://\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"URL de conexión del navegador\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"Las URL del navegador deben comenzar con wss:// o ws://\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"Solicitudes de texto sin formato\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Solicitudes de Chrome\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"Proxy predeterminado\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"Segundos de fluctuación aleatoria ± verificación\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"Número de trabajadores de búsqueda\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"Debe estar entre 1 y 50\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"Solicita tiempo de espera en segundos\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"Debe estar entre 1 y 999\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"Anulaciones de agente de usuario predeterminado\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"Se requieren tanto un nombre como una URL de proxy.\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"Abra la página 'Historial' en una nueva pestaña\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"Actualizaciones de UI en tiempo real habilitadas\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"Favicones habilitados\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"Usar <title> de la página en la lista general de monitores\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"Comprobación de seguridad del token de acceso API habilitada\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"Anulación de URL de base de notificaciones\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"¿Tratar las páginas vacías como un cambio?\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"Ignorar texto\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"Ignorar espacios en blanco\"\n\n#: changedetectionio/forms.py\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"Debe estar entre 0 y 100\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"Contraseña\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"Tamaño del buscapersonas\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"Debe ser al menos cero (deshabilitado)\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"Formato de contenido RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"RSS<description>cuerpo construido a partir de\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"Anulación de plantilla RSS \\\"Predeterminada del sistema\\\"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"Quitar contraseña\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"Representar el contenido de la etiqueta de anclaje\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"\"\n\"Permitir el acceso anónimo a la página del historial de visualización cuando\"\n\" la contraseña esté habilitada\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"Ocultar monitores silenciados del feed RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"Habilitar el modo lector RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"Número de cambios que se mostrarán en la fuente RSS del monitor\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"Debe contener cero o más intentos.\"\n\n#: changedetectionio/forms.py\nmsgid \"\"\n\"Number of times the filter can be missing before sending a notification\"\nmsgstr \"\"\n\"Número de veces que puede faltar el filtro antes de enviar una notificación\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"RegEx para extraer\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"Extraer como CSV\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"\"\n\"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\"No se encontraron coincidencias al escanear todo el historial de \"\n\"visualización para esa expresión regular.\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"\"\n\"No hay suficiente historia para comparar. Necesita al menos 2 instantáneas.\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"No se pudieron cargar capturas de pantalla:{}\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"No se pudo calcular la diferencia:{}\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"El valor del cuadro delimitador es demasiado largo\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"\"\n\"El cuadro delimitador debe tener el formato: x,y,ancho,alto (solo números \"\n\"enteros)\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"Los valores del cuadro delimitador no deben ser negativos\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"Los valores del cuadro delimitador son demasiado grandes\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"El modo de selección debe ser \\\"elemento\\\" o \\\"dibujo\\\"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"Porcentaje mínimo de cambio\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"Sensibilidad de diferencia de píxeles\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"Usar valor predeterminado global\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"Cuadro delimitador\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"Modo de selección\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"El valor del modo de selección es demasiado largo\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"Comparación de capturas de pantalla\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"Vista previa no disponible: aún no se han capturado instantáneas\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"Detección de cambios de captura de pantalla visual/imagen\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"\"\n\"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"\"\n\"Compara capturas de pantalla utilizando el rápido algoritmo OpenCV, entre 10\"\n\" y 100 veces más rápido que SSIM\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"Detección de reabastecimiento\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"Solo en stock (Agotado -> Solo en stock)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"Cualquier cambio de disponibilidad\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"Apagado, no seguir disponibilidad/reabastecimiento\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"Precio inferior para activar la notificación\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"Sin límite\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"Precio superior para activar la notificación\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"Umbral en %% fo cambios de precio desde el precio original\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"Debe estar entre 0 y 100\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"Seguir los cambios de precios\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"Reabastecimiento y detección de precios\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"\"\n\"Reabastecimiento y detección de precios para páginas con un ÚNICO producto\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"Detecta si el producto vuelve a estar en stock\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"Cambios en el texto/HTML, JSON y PDF de la página web\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"Detecta todos los cambios de texto siempre que sea posible.\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"Error al obtener metadatos para{}\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"\"\n\"El protocolo de visualización no está permitido o el formato de URL no es \"\n\"válido\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\"Límite de visualización alcanzado ({} /{} monitores). No se pueden agregar \"\n\"más monitores.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"Cuerpo de todas las notificaciones: puedes utilizar\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"\"\n\"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\"plantillas en el título, el cuerpo y la URL de la notificación, y tokens \"\n\"desde abajo.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"Mostrar token/marcadores de posición\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"Simbólico\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"Descripción\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"La URL de la instancia de changetection.io que está ejecutando.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"La URL que se está viendo.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"El UUID del monitor.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"\"\n\"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\"El título de la página del monitor, utiliza<title>si no se establece, vuelve\"\n\" a la URL\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"El grupo/etiqueta del monitor\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"La URL de la página de vista previa generada por changetection.io.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"La URL de la salida de diferenciación para el monitor.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"La salida de diferencias: solo cambios, adiciones y eliminaciones\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"La salida de diferencias (solo cambios, adiciones y eliminaciones)\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"Sin prefijo ni colores (añadidos)\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"La salida del diferencial: solo cambios y adiciones\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"La salida del diferencial (solo cambios y adiciones)\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"La salida del diferencial: solo cambios y eliminaciones\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"La salida del diferencial (solo cambios y eliminaciones)\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"La salida diff - salida de diferencia completa\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"La salida diff - salida de diferencia completa -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"La salida diff - parche en formato unificado\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"\"\n\"The current snapshot text contents value, useful when combined with JSON or \"\n\"CSS filters\"\nmsgstr \"\"\n\"El valor del contenido del texto de la instantánea actual, útil cuando se \"\n\"combina con filtros JSON o CSS\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"Texto que activó el disparador de los filtros\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"Advertencia: Contenido de\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"y\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"Depende de cómo el algoritmo de diferencia percibe el cambio.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"\"\n\"For example, an addition or removal could be perceived as a change in some \"\n\"cases.\"\nmsgstr \"\"\n\"Por ejemplo, una adición o eliminación podría percibirse como un cambio en \"\n\"algunos casos.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"Más aquí\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"URL de notificación de AppRise\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"para notificar a casi cualquier servicio!\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"\"\n\"Please read the notification services wiki here for important configuration \"\n\"notes\"\nmsgstr \"\"\n\"Lea la wiki de servicios de notificación aquí para obtener notas de \"\n\"configuración importantes.\"\n\n#: changedetectionio/templates/_common_fields.html\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"Usar\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"Mostrar ayuda y consejos avanzados\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"(o\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"sólo admite un máximo\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"2.000 caracteres\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"del texto de la notificación, incluido el título.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"\"\n\"bots can't send messages to other bots, so you should specify chat ID of \"\n\"non-bot user.\"\nmsgstr \"\"\n\"Los bots no pueden enviar mensajes a otros bots, por lo que debes \"\n\"especificar el ID de chat de un usuario que no sea un bot.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\"solo admite HTML muy limitado y puede fallar cuando se envían etiquetas \"\n\"adicionales,\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"(o utilice formato de texto sin formato/rebajas)\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"para llamadas API directas (u omitir el\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"para no SSL, es decir\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"más ayuda aquí\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"Acepta el\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"marcadores de posición enumerados a continuación\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"Enviar notificación de prueba\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"Agregar correo electrónico\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"Agregar una dirección de correo electrónico\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"Registros de depuración de notificaciones\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"Tratamiento..\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"Título para todas las notificaciones.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"Para cargas JSON, utilice\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"sin comillas para escape automático, por ejemplo -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"Codificación de URL, uso\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"Por ejemplo -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"Reemplazo de expresión regular, uso\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"\"\n\"For a complete reference of all Jinja2 built-in filters, users can refer to \"\n\"the\"\nmsgstr \"\"\n\"Para obtener una referencia completa de todos los filtros integrados de \"\n\"Jinja2, los usuarios pueden consultar el\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"Formato para todas las notificaciones.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"Entrada\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"Comportamiento\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"Agregar una fila/regla después\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"Eliminar esta fila/regla\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"Verifique esta regla con la instantánea actual\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"\"\n\"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but \"\n\"Chrome based fetching is not enabled.\"\nmsgstr \"Error: este monitor necesita Chrome (con playwright/sockpuppetbrowser), pero la obtención basada en Chrome no está habilitada.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"Alternativamente prueba nuestro\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"\"\n\"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\"Servicio basado en suscripción muy asequible que tiene toda esta \"\n\"configuración para usted.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"Es posible que necesites\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"Habilitar la variable de entorno de playwright\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"y descomentar el\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"en el\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"archivo\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"Establecer un horario por horas/días de la semana\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"Programar límites de tiempo\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"Horario comercial\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"Fines de semana\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"Reiniciar\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"\"\n\"Warning, one or more of your 'days' has a duration that would extend into \"\n\"the next day.\"\nmsgstr \"\"\n\"Advertencia, uno o más de sus 'días' tienen una duración que se extendería \"\n\"hasta el día siguiente.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"Esto podría tener consecuencias no deseadas.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"Más ayuda y ejemplos sobre el uso del planificador\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"¿Quieres utilizar un horario?\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"Primero confirme/guarde su configuración de zona horaria\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"\"\n\"Triggers a change if this text appears, AND something changed in the \"\n\"document.\"\nmsgstr \"Activa un cambio si aparece este texto Y algo cambia en el documento.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"Texto activado\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"Se ignora para calcular los cambios, pero aún se muestra.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"Texto ignorado\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"\"\n\"No se producirá ninguna detección de cambios porque este texto existe.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"Texto bloqueado\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"Buscar o usar la tecla Alt+S\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"Actualizaciones en tiempo real sin conexión\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"Seleccionar idioma\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"Detectar automáticamente desde el navegador\"\n\n#: changedetectionio/templates/base.html\nmsgid \"\"\n\"Language support is in beta, please help us improve by opening a PR on \"\n\"GitHub with any updates.\"\nmsgstr \"\"\n\"El soporte de idiomas está en versión beta. Ayúdenos a mejorar abriendo un \"\n\"PR en GitHub con cualquier actualización.\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"Buscar\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"URL o título\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"en\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"Introduzca el término de búsqueda...\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Text to wait for before triggering a change/notification, all text and regex\"\n\" are tested case-insensitive.\"\nmsgstr \"\"\n\"Texto a esperar antes de activar un cambio/notificación, todo el texto y las\"\n\" expresiones regulares se prueban sin distinguir entre mayúsculas y \"\n\"minúsculas.\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Trigger text is processed from the result-text that comes out of any \"\n\"CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\"El texto de activación se procesa a partir del texto de resultado que surge \"\n\"de cualquier filtro CSS/JSON para este monitor.\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"Cada línea se procesa por separado (piense en cada línea como \\\"O\\\")\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\"Nota: incluya una barra diagonal / para usar el ejemplo de expresiones \"\n\"regulares:\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"También puedes usar\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"condiciones\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\\\"Texto de página\\\": contiene, comienza con, no contiene y muchos más\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Matching text will be ignored in the text snapshot (you can still see it but\"\n\" it wont trigger a change)\"\nmsgstr \"\"\n\"El texto coincidente se ignorará en la instantánea del texto (aún podrás \"\n\"verlo pero no activará un cambio)\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex \"\n\"are tested case-insensitive, good for waiting for when a product is \"\n\"available again\"\nmsgstr \"\"\n\"Bloquear la detección de cambios mientras este texto está en la página, todo\"\n\" el texto y las expresiones regulares se prueban sin distinguir entre \"\n\"mayúsculas y minúsculas, lo cual es bueno para esperar a que un producto \"\n\"vuelva a estar disponible\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block text is processed from the result-text that comes out of any CSS/JSON \"\n\"Filters for this monitor\"\nmsgstr \"\"\n\"El texto del bloque se procesa a partir del texto resultante que surge de \"\n\"cualquier filtro CSS/JSON para este monitor.\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"Todas las líneas aquí no deben existir (piense en cada línea como \\\"O\\\")\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Extracts text in the final output (line by line) after other filters using \"\n\"regular expressions or string match:\"\nmsgstr \"\"\n\"Extrae texto en la salida final (línea por línea) después de otros filtros \"\n\"usando expresiones regulares o coincidencia de cadenas:\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"Expresión regular - ejemplo\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"No olvides considerar el espacio en blanco al comienzo de una línea.\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"tipo banderas (más\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"información aquí\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"Ejemplo de palabra clave - ejemplo\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"Utilice grupos para extraer solo ese texto: ejemplo\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"devuelve una lista de años solamente\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"Ejemplo: líneas coincidentes que contienen una palabra clave\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"Una línea por coincidencia de cadena/expresión regular\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"Acceso\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"GRUPOS\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"AJUSTES\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"IMPORTAR\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"Reanudar la programación automática\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"Pausar la programación de monitores en cola automática\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"La programación está en pausa: haga clic para reanudar\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"Dejar de silenciar notificaciones\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"Silenciar notificaciones\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"\"\n\"Las notificaciones están silenciadas: haga clic para activar el silencio\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"EDITAR\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"FINALIZAR LA SESIÓN\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"Detección y notificación de cambios en el sitio web.\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"Alternar modo claro/oscuro\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"Alternar modo claro/oscuro\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"Cambiar idioma\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"Cambiar idioma\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"Sí\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"No\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"Configuraciones principales\"\n\n"
  },
  {
    "path": "changedetectionio/translations/fr/LC_MESSAGES/messages.po",
    "content": "# French translations for PROJECT.\n# Copyright (C) 2026 ORGANIZATION\n# This file is distributed under the same license as the PROJECT project.\n# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PROJECT VERSION\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-02 11:40+0100\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language: fr\\n\"\n\"Language-Team: fr <LL@li.org>\\n\"\n\"Plural-Forms: nplurals=2; plural=(n > 1);\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"Une sauvegarde est déjà en cours, revenez dans quelques minutes\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"Nombre maximum de sauvegardes atteint, veuillez en supprimer quelques-unes\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"Sauvegarde en cours de création en arrière-plan, revenez dans quelques minutes.\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"Les sauvegardes ont été supprimées.\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"Une sauvegarde est en cours !\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"Mo\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"Aucune sauvegarde trouvée.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"Créer sauvegarde\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"Supprimer sauvegardes\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"Importation de 5 000 des premières URL de votre liste, le reste peut être importé à nouveau.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"{} importées de la liste en {:.2f}s, {} ignorées.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"Impossible de lire le fichier JSON, est-il corrompu ?\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"La structure JSON semble invalide, est-elle corrompue ?\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"{} importées de Distill.io en {:.2f}s, {} ignorées.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"Impossible de lire le fichier XLSX d'exportation, quelque chose ne va pas avec le fichier ?\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"Erreur lors du traitement de la ligne numéro {}, la valeur de l'URL était incorrecte, la ligne a été ignorée.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"\"\n\"Erreur lors du traitement de la ligne numéro {}, vérifiez que tous les types de données des cellules sont corrects, \"\n\"la ligne a été ignorée.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"{} importées de Wachete .xlsx en {:.2f}s\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"{} importées de .xlsx personnalisé en {:.2f}s\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"Liste d'URL\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX et Wachete\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"Exemple:\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"Les URL qui ne passent pas la validation resteront dans la zone de texte.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"C'est\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"expérimental\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"les champs pris en charge sont\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"le reste (y compris\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"sont ignorés.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"Comment exporter ?\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"Assurez-vous de définir votre outil de récupération par défaut sur Chrome si nécessaire.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"Tableau de mappage des colonnes personnalisées et des types de données pour le\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"Cartographie personnalisée\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"Type de mappage de fichiers.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"Colonne #\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"Taper\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"aucun\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"Filtre CSS/xPath\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"Groupe de surveillance/tag\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"Temps de revérification (minutes)\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"IMPORTER\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"Avertissement: Le nombre de workers ({}) approche ou dépasse les cœurs CPU disponibles ({})\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"PARAMÈTRES\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"Toutes les notifications sont désactivées.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"Toutes les notifications sont activées.\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"Journal de débogage des notifications\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"Général\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"Récupération\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"Filtres globaux\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"Options de l'interface utilisateur\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"SAUVEGARDES\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"Heure et date\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"CAPTCHA et procurations\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"Info\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"Heure de revérification par défaut pour tous les moniteurs, le minimum actuel du système est\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"Plus d'informations\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"Nombre de fois consécutives où le filtre CSS/xPath est manquant avant l'envoi d'une notification\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"Définir à\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"\"\n\"Autoriser l'accès à la page d'historique des changements lorsque le mot de passe est activé (utile pour partager la \"\n\"page de diff)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"Choisir un proxy par défaut pour tous les moniteurs\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"token dans les liens de notification.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"en savoir plus ici\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"Utilisez le\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"Basique\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"Le\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"Chrome/Javascript\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"Cela attendra\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"secondes avant d’extraire le texte.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"En cours d'exécution:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"opérationnel\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"workers\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"en traitement actif\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"Conseil:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"Connectez-vous à l'aide des proxys Bright Data et Oxylabs, découvrez-en plus ici.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"Note:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"ignoré\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"Accès API et exemples ici\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"Clé API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Extension Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Chrome Webstore\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"Astuce\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"Version Python :\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"Plugins actifs :\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"Aucun plugin actif\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"Retour\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"Effacer/réinitialiser l'historique\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"Ajouté\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"Muet\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"Filtres et déclencheurs\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"PARAMÈTRES\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"ajouté\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"à toutes les configurations de moniteur existantes.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"Filtrage de texte\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"A utiliser avec prudence !\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"Cela remplira facilement votre quota de stockage de courrier électronique ou inondera d’autres stockages.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"DÉCONNEXION\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"DÉCONNEXION\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"Il y a\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"URL de notification à l'échelle du système activées\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"ce formulaire remplacera les paramètres de notification pour ce moniteur uniquement\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"une liste d'URL de notification vide ici enverra toujours des notifications.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"Utiliser les paramètres par défaut du système\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"Ajouter une nouvelle balise organisationnelle\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"Groupe / Étiquette\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"# Moniteurs\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"Nom de l'étiquette/de l'étiquette\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"Aucun groupe/étiquette configuré\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"Modifier\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"Revérifier\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"Supprimer le groupe ?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"Supprimer\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"Supprime et supprime la balise\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"Dissocier le groupe ?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"Dissocier\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"Conservez l'étiquette mais dissociez les moniteurs\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"Flux RSS de ce moniteur\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"{} moniteurs en pause\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"{} moniteurs en sourdine\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"{} moniteurs configurés pour utiliser les paramètres de notification par défaut\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"Surveillance non trouvée\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"Historique effacé pour le moniteur {}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"Aucune information\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"Supprimer\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"{} moniteurs mis en file d'attente ({} déjà en file ou en cours).\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"{} surveillances mises en file d'attente pour revérification.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"Mise en file des moniteurs pour revérification en arrière-plan...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"Langue définie sur la détection automatique depuis le navigateur\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"Supprimer les montres ?\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"Vous aimerez peut-être utiliser le\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"SAUVEGARDES\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"lien d'abord.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"Texte de confirmation\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"Tapez le mot\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"clair\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"pour confirmer que vous comprenez.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"Effacer les historiques\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"Annuler\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"Partager la différence sous forme d'image\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"Partager en tant qu'image\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"Ignorer toutes les lignes correspondant\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"Ignorer toutes les lignes correspondant à l'exclusion des chiffres\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"Depuis\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"À\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"Mots\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"Lignes\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"Ignorer les espaces\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"Identique/non modifié\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"Supprimé\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"Ajouté\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"Remplacé\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"Clavier:\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"Aperçu\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"Suivant\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"Passer à la différence suivante\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"Saut\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"Texte d'erreur\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"Capture d'écran d'erreur\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"Texte\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"Capture d'écran actuelle\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"Extraire des données\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"il y a quelques secondes.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"il y a quelques secondes\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"Capture d'écran d'erreur actuelle de la demande la plus récente\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"Conseil de pro : vous pouvez activer\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"PARAMÈTRES\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"Accéder à un seul instantané\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"Mettez en surbrillance le texte à partager ou à ajouter pour ignorer les listes.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"Capture d'écran actuelle de la demande la plus récente\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"Aucune capture d'écran disponible pour l'instant ! Essayez de revérifier la page.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"La capture d'écran nécessite l'activation de Playwright/WebDriver\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"Demande\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"Étapes du navigateur\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"Sélecteur de filtre visuel\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"Conditions\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"Statistiques\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"Certains sites utilisent JavaScript pour créer le contenu, pour cela vous devez\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"utilisez le récupérateur Chrome/WebDriver\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"Les variables sont prises en charge dans l'URL\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"aide et exemples ici\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"\"\n\"Nom du groupe/étiquetteNom du groupe/étiquetteBalise organisationnelle/nom de groupe utilisé dans la page de liste \"\n\"principale\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"L'intervalle/la durée entre chaque vérification.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"Revérifiez tout\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"Choisissez un proxy pour ce moniteur\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"Utilisation des paramètres globaux par défaut actuels\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"Afficher les options avancées\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"Plus d'aide et d'exemples ici\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"Les variables sont prises en charge dans le corps de la requête\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"Les variables sont prises en charge dans les valeurs d'en-tête de requête\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"Alerte! Fichier d'en-têtes supplémentaire trouvé et sera ajouté à cette montre !\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"Les en-têtes peuvent également être lus à partir d'un fichier dans votre répertoire de données\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"Lire la suite ici\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"Non pris en charge par le navigateur Selenium\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"Activer la recherche de texte\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"Veuillez patienter, le chargement de la première étape du navigateur peut prendre un peu de temps.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"Cliquez ici pour commencer\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"Veuillez attendre 10 à 15 secondes pour que le navigateur se connecte.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"Les données du sélecteur visuel ne sont pas prêtes, le moniteur doit être vérifié au moins une fois.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"à celui qui prend en charge Javascript interactif.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"Vous devez\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"Définir la méthode de récupération\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"Lisez un tutoriel rapide sur\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"en utilisant les modifications conditionnelles de page Web ici\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"Aperçu\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"Conseils de pro :\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"Utilisez la page d'aperçu pour voir vos filtres et déclencheurs mis en surbrillance.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"Limiter le déclenchement/ignorer/bloquer/extraire à ;\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"au lieu de\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"remplacement\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"Par exemple.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"ajout\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"Il est donc toujours préférable de sélectionner\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"lorsque vous êtes intéressé par du nouveau contenu.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"Lorsque le contenu est simplement déplacé dans une liste, cela déclenchera également un\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"envisager d'activer\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"Se déclenche uniquement lorsque des lignes uniques apparaissent\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"vérifier les lignes uniques\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"ci-dessous.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"Supprimez tout espace avant et après chaque ligne de texte\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"Chargement...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"L'outil Visual Selector vous permet de sélectionner le\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"texte\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"Pause\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"Maj+Clic\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"pour sélectionner plusieurs éléments.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"Mode de sélection :\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"Sélectionner par élément\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"Zone de dessin\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"Effacer la sélection\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"Un instant, récupération de la capture d'écran et des informations sur les éléments.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"Actuellement:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"à celui qui prend en charge Javascript et les captures d'écran.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"Nombre de chèques\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"Pannes de filtre consécutives\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"Histoire\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"Durée de la dernière récupération\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"Nombre d'alertes de notification\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"Réponse du type de serveur\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"Télécharger le dernier instantané HTML\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"Supprimer les montres ?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"Êtes-vous sûr de vouloir supprimer la montre pour :\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"Cette action ne peut pas être annulée.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"Effacer les historiques\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"Êtes-vous sûr de vouloir effacer tout l'historique pour :\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"Effacer les historiques\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"Cloner et modifier\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"Sélectionnez l'horodatage\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"Aller\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"Capture d'écran erronée actuelle de la demande la plus récente\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"affichage de <b>{start} - {end}</b> {record_name} sur un total de <b>{total}</b>\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"enregistrements\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"Plus d'informations\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"Ajouter une nouvelle surveillance de détection de changement de page Web\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"Surveillez cette URL !\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"Modifier > Surveiller\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"Pause\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"Reprendre la pause\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"Muet\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"Réactiver le son\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"Étiqueter\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"Marquer consulté\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"Utiliser la notification par défaut\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"Effacer les erreurs\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"Effacer les historiques\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"D'ACCORD\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"Effacer/réinitialiser l'historique\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"Supprimer les montres ?\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"Taille de la file\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"Recherche\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"Tous\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"Site web\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"Réapprovisionnement et prix\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"Vérification\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"Dernier\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"Modifié\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"Aucune surveillance de site Web configurée, veuillez ajouter une URL dans la case ci-dessus, ou\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"importer une liste\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"Détection du réapprovisionnement et du prix\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"En stock\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"Pas en stock\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"Prix\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"Aucune information\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"Vérifier maintenant\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"En file d'attente\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"Historique\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"Aperçu\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"Avec des erreurs\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"Marquer tous les articles consultés\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"Marquer tout vu dans '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"Non lu\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"Revérifiez tout\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"dans '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"Pas encore\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"secondes\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"Déjà connecté\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"Vous devez être connecté, veuillez vous connecter.\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"Mot de passe incorrect\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"Au moins une période doit être spécifiée (semaines, jours, heures, minutes, ou secondes).\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"\"\n\"Au moins une période doit être spécifiée (semaines, jours, heures, minutes, ou secondes) quand les paramètres \"\n\"généraux ne sont pas utilisés.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"Format d'heure invalide. Utilisez HH:MM.\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"Nom de fuseau horaire invalide\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"non défini\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"Débute à\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"Durée d'exécution\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"Utiliser le programmateur\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"Fuseau horaire facultatif dans lequel exécuter\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"Lundi\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"Mardi\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"Mercredi\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"Jeudi\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"Vendredi\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"Samedi\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"Dimanche\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"Semaines\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"Doit contenir zéro ou plusieurs secondes\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"Jours\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"Heures\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"Minutes\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"secondes\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"Le corps et le titre de la notification sont requis lorsqu'une URL de notification est utilisée\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"'%s' n'est pas une URL AppRise valide.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"RegEx '%s' n'est pas une expression régulière valide.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"'%s' n'est pas une expression XPath valide. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"'%s' n'est pas une expression JSONPath valide. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"'%s' n'est pas une expression jq valide. (%s)\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"Valeur vide non autorisée.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"Valeur invalide.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"Groupe / Étiquette\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"Moniteur\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"Processeur\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"Modifier > Surveiller\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"Définir la méthode de récupération\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"Corps de notification\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"Format de notification\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"Titre de notification\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"Liste d'URL de notification\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"Processeur – Que souhaitez-vous réaliser ?\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"Fuseau horaire par défaut pour le planificateur de contrôle de surveillance\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"secondes avant d’extraire le texte.\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"Doit contenir une ou plusieurs secondes\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"Téléchargez le fichier .xlsx\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"Il doit s'agir d'un fichier .xlsx !\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"Type de mappage de fichiers.\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"Options de l'interface utilisateur\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"Mode de sélection :\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"Pause\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"Intervalle de vérification\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"Utilisez les paramètres globaux pour le temps entre la vérification et le programmateur.\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"Filtre CSS/JSONPath/JQ/XPath\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"Supprimer par élément\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"Extraire des données\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"Titre\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"Ignorer toutes les lignes correspondant\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"Demande\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"Demande\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"Ignorer les codes d'état (traiter les codes d'état non-2xx comme d'habitude)\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"Se déclenche uniquement lorsque des lignes uniques apparaissent\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"Supprimer les lignes de texte en double\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"Trier le texte par ordre alphabétique\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"Supprimer les lignes ignorées\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"Supprimez tout espace avant et après chaque ligne de texte\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"Lignes ajoutées\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"Lignes remplacées/modifiées\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"Lignes supprimées\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"Déclencheurs de mots clés – Déclencher/attendre du texte\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"Bloquer la détection des modifications lorsque le texte correspond\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"Exécuter JavaScript avant la détection des modifications\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"Sauvegarder\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"Proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"Envoyer une notification lorsque le filtre n'est plus trouvé sur la page\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"Muet\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"Activé\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"Notifications\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"Joindre une capture d'écran à la notification (si possible)\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"Correspondance\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"Faites correspondre tous les éléments suivants\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"Faites correspondre l'un des éléments suivants\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"Utiliser la page <titre> dans la liste\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"Le corps doit être vide lorsque la méthode de requête est définie sur GET\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"Configuration de syntaxe de modèle non valide : %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"Syntaxe de modèle non valide : %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"Nom\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"URL du proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"Les URL proxy doivent commencer par http://, https:// ou socks5://\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"URL de connexion au navigateur\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"Les URL du navigateur doivent commencer par wss:// ou ws://\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"Requêtes en texte brut\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Requêtes Chrome\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"Proxy par défaut\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"Secondes de gigue aléatoire ± vérification\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"Nombre de travailleurs de récupération\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"Doit être compris entre 1 et 50\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"Délai d'expiration des demandes en secondes\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"Doit être compris entre 1 et 999\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"Remplacements de l'agent utilisateur par défaut\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"Un nom et une URL de proxy sont requis.\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"Ouvrir la page \\\"Historique\\\" dans un nouvel onglet\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"Mises à jour en temps réel hors ligne\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"Favicons Activés\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"Utiliser la page <titre> dans la liste de présentation des moniteurs\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"Contrôle de sécurité du jeton d'accès à l'API activé\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"URL de base pour les notifications\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"Considérer les pages vides comme un changement ?\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"Ignorer le texte\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"Ignorer les espaces\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"Doit être compris entre 0 et 100\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"Mot de passe\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"Taille de la mémoire\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"Doit être au moins zéro (désactivé)\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"Format du contenu RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"RSS <description> corps construit à partir de\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"Ecraser le template RSS par défaut\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"Enlever le mot de passe\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"Afficher le contenu de la balise d'ancrage\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"Autoriser l'accès anonyme à la page de l'historique des vidéos regardées lorsque le mot de passe est activé\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"Masquer les moniteurs en sourdine du flux RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"Activer le mode lecteur RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"Nombre de modifications à afficher dans le flux RSS du moniteur\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"Doit contenir zéro ou plusieurs tentatives\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"Nombre de fois où le filtre peut être manquant avant l'envoi d'une notification\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"RegEx à extraire\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"Extraire des données\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"La valeur du cadre de sélection est trop longue\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"Le cadre de délimitation doit être au format : x,y,largeur,hauteur (entiers uniquement)\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"Les valeurs du cadre de délimitation doivent être non négatives\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"Les valeurs du cadre de délimitation sont trop grandes\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"Pourcentage de changement minimum\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"Sensibilité de différence de pixel\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"Utiliser les paramètres par défaut du système\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"Boîte englobante\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"Mode de sélection :\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"La valeur du mode de sélection est trop longue\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"Comparaison de captures d'écran\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"Détection des changements de capture d'écran visuel/image\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"Détection de réapprovisionnement\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"En stock uniquement (En rupture de stock -> En stock uniquement)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"Tout changement de disponibilité\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"Désactivé, ne suivez pas la disponibilité/réapprovisionnement\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"En dessous du prix pour déclencher une notification\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"Aucune limite\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"Au-dessus du prix pour déclencher une notification\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"Seuil en % pour les changements de prix depuis le prix initial\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"Doit être compris entre 0 et 100\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"Suivre les changements de prix\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"Réapprovisionnement et prix\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"Détection de réapprovisionnement et de prix pour les pages avec un SEUL produit\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"Détecte si le produit revient en stock\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"Modifications du texte de la page Web/HTML, JSON et PDF\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"Détecte toutes les modifications de texte lorsque cela est possible\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"Corps pour toutes les notifications — Vous pouvez utiliser\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"Description\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"L'UUID du moniteur.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"Le groupe / tag du moniteur\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"et\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"Plus d'infos ici\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"URLs de notification AppRise\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"Utiliser\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"Afficher l'aide et astuces avancées\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"du texte de notification, y compris le titre.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"plus d'aide ici\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"Envoyer notification de test\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"Journaux de débogage des notifications\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"Traitement en cours..\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"Titre pour toutes les notifications\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"par exemple -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"Format pour toutes les notifications\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"Actions\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"Vous devrez peut-être\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"dans le\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"fichier\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"Limites horaires\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"Week-ends\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"Réinitialiser\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"Plus d'aide sur le planificateur\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"Voulez-vous utiliser un planificateur horaire?\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"Texte déclencheur\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"Texte ignoré\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"Aucune détection de changement si ce texte existe.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"Texte bloqué\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"Recherchez ou utilisez la touche Alt+S\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"Mises à jour en temps réel hors ligne\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"Sélectionnez la langue\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"Détecter automatiquement depuis le navigateur\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"\"\n\"Le support linguistique est en version bêta, veuillez nous aider à améliorer en ouvrant une PR sur GitHub avec vos \"\n\"mises à jour.\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"Rechercher\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"dans\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"informations ici\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"Se connecter\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"GROUPES\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"PARAMÈTRES\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"IMPORTER\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"Réactiver les notifications\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"Désactiver les notifications\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"Notifications désactivées - cliquez pour réactiver\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"MODIFIER\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"DÉCONNEXION\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"Basculer le mode clair/sombre\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"Basculer en mode clair/sombre\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"Changer de langue\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"Changer de langue\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"Oui\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"Non\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"Paramètres principaux\"\n\n#~ msgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\n#~ msgstr \"Compare les captures d'écran à l'aide de l'algorithme OpenCV rapide, 10 à 100 fois plus rapide que SSIM\"\n\n#~ msgid \"Actions\"\n#~ msgstr \"Conditions\"\n\n#~ msgid \"You may need to\"\n#~ msgstr \"Vous devez\"\n\n#~ msgid \"in the\"\n#~ msgstr \"Le\"\n\n#~ msgid \"file\"\n#~ msgstr \"Titre\"\n\n#~ msgid \"Schedule time limits\"\n#~ msgstr \"Temps de revérification (minutes)\"\n\n#~ msgid \"Weekends\"\n#~ msgstr \"Semaines\"\n\n#~ msgid \"Reset\"\n#~ msgstr \"Demande\"\n\n#~ msgid \"More help and examples about using the scheduler\"\n#~ msgstr \"Plus d'aide et d'exemples ici\"\n\n#~ msgid \"Want to use a time schedule?\"\n#~ msgstr \"Utiliser le planificateur de temps\"\n\n#~ msgid \"Triggered text\"\n#~ msgstr \"Texte d'erreur\"\n\n#~ msgid \"Ignored text\"\n#~ msgstr \"Texte d'erreur\"\n\n#~ msgid \"No change-detection will occur because this text exists.\"\n#~ msgstr \"Bloquer la détection des modifications lorsque le texte correspond\"\n\n#~ msgid \"Blocked text\"\n#~ msgstr \"Texte d'erreur\"\n\n#~ msgid \"Search\"\n#~ msgstr \"Recherche\"\n\n#~ msgid \"in\"\n#~ msgstr \"Plus d'informations\"\n\n#~ msgid \"Visual\"\n#~ msgstr \"Visuel\"\n\n#~ msgid \"Restock\"\n#~ msgstr \"Réapprovisionner\"\n\n#~ msgid \"Watch List\"\n#~ msgstr \"Moniteurs\"\n\n#~ msgid \"Watches\"\n#~ msgstr \"Moniteurs\"\n\n#~ msgid \"Queue\"\n#~ msgstr \"En file d'attente\"\n\n#~ msgid \"Cleared snapshot history for all watches\"\n#~ msgstr \"Effacer/réinitialiser l'historique\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"Créer un lien partageable\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"Astuce : Vous pouvez également ajouter des montres « partagées ».\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/translations/it/LC_MESSAGES/messages.po",
    "content": "# Italian translations for PROJECT.\n# Copyright (C) 2026 ORGANIZATION\n# This file is distributed under the same license as the PROJECT project.\n# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PROJECT VERSION\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-02 15:32+0100\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language: it\\n\"\n\"Language-Team: it <LL@li.org>\\n\"\n\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"Un backup è già in esecuzione, riprova tra qualche minuto\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"Numero massimo di backup raggiunto, rimuovine alcuni\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"Backup in creazione in background, riprova tra qualche minuto.\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"I backup sono stati eliminati.\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"Un backup è in esecuzione!\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"MB\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"Nessun backup trovato.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"Crea backup\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"Rimuovi backup\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"Importazione delle prime 5.000 URL dalla tua lista, il resto può essere importato di nuovo.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"{} Importate dalla lista in {:.2f}s, {} Ignorate.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"Impossibile leggere il file JSON, è danneggiato?\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"La struttura JSON sembra non valida, è danneggiata?\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"{} Importate da Distill.io in {:.2f}s, {} Ignorate.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"Impossibile leggere il file XLSX di esportazione, c'è qualcosa che non va con il file?\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"Errore nell'elaborazione della riga numero {}, il valore dell'URL non era corretto, riga ignorata.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"\"\n\"Errore nell'elaborazione della riga numero {}, verifica che tutti i tipi di dati delle celle siano corretti, riga \"\n\"ignorata.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"{} importate da Wachete .xlsx in {:.2f}s\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"{} importate da .xlsx personalizzato in {:.2f}s\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"Lista URL\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX & Wachete\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"Esempio:\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"Questo è\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"sperimentale\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"Tipo\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"nessuno\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"Filtro CSS/xPath\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"Nome gruppo / Tag\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"Tempo di ricontrollo (minuti)\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"Importa\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"Avviso: Il numero di worker ({}) si avvicina o supera i core CPU disponibili ({})\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"Tutte le notifiche disattivate.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"Tutte le notifiche attivate.\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"Generale\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"Recupero\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"Filtri globali\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"Opzioni interfaccia\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"Backup\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"Data e ora\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"CAPTCHA e Proxy\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"Info\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"Numero di volte consecutive in cui il filtro CSS/xPath manca prima di inviare notifica\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"Consenti accesso alla pagina cronologia quando la password è attiva (utile per condividere la pagina diff)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"token nei link di notifica.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"operativo\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"workers\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"in elaborazione\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"Nota:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"ignorato\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"Chiave API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Estensione Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Chrome Webstore\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"Indietro\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"Cancella cronologia snapshot\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"Aggiornato\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"Gruppo / Etichetta\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"# Monitoraggi\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"Nessun gruppo/etichetta configurato\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"Modifica\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"Controlla ora\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"Elimina\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"Monitoraggio non trovato\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"{} monitor in coda ({} già in coda o in esecuzione).\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"{} monitoraggi accodati per la riverifica.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"Accodamento monitor per riverifica in background...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"Lingua impostata su rilevamento automatico dal browser\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"Testo di conferma\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"Annulla\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"Parole\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"Righe\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"Uguale/non modificato\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"Rimosso\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"Aggiunto\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"Sostituito\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"Precedente\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"Successivo\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"Testo dell'errore\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"Screenshot dell'errore\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"Testo\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"Screenshot corrente\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"secondi fa.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"secondi fa\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"Screenshot corrente dalla richiesta più recente\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"Richiesta\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"Condizioni\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"Statistiche\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"Nome gruppo/etichetta\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"Caricamento...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"Cancella cronologia\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"Clona e Modifica\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"Vai\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"visualizzando <b>{start} - {end}</b> {record_name} su un totale di <b>{total}</b>\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"record\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"Maggiori informazioni\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"Aggiungi un nuovo monitoraggio modifiche pagina web\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"Monitora questo URL!\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"Modifica > Monitora\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"Pausa\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"Riprendi\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"Silenzia\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"Riattiva audio\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"Tag\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"Segna come visualizzato\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"Usa notifica predefinita\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"Cancella errori\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"Cancella cronologie\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"OK\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"Cancella/ripristina cronologia\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"Eliminare monitoraggi?\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"Dimensione coda\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"Ricerca in corso\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"Tutti\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"Sito web\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"Controllo\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"Ultimo\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"Modifica\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"Nessun monitoraggio configurato, aggiungi un URL nella casella sopra, oppure\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"importa una lista\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"Disponibile\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"Non disponibile\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"Prezzo\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"Nessuna informazione\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"Controllo in corso\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"In coda\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"Cronologia\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"Anteprima\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"Con errori\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"Segna tutti come visualizzati\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"Non letto\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"Controlla tutti\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"Non ancora\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"secondi\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"Già autenticato\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"Devi essere autenticato, effettua l'accesso.\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"Password errata\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"Formato orario non valido. Usa HH:MM.\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"Nome fuso orario non valido\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"non impostato\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"Inizio\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"Durata esecuzione\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"Usa pianificazione oraria\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"Fuso orario opzionale per l'esecuzione\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"Lunedì\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"Martedì\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"Mercoledì\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"Giovedì\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"Venerdì\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"Sabato\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"Domenica\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"Settimane\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"Deve contenere zero o più secondi\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"Giorni\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"Ore\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"Minuti\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"Secondi\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"Corpo e titolo notifica sono richiesti quando si usa un URL di notifica\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"'%s' non è un URL AppRise valido.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"La RegEx '%s' non è un'espressione regolare valida.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"'%s' non è un'espressione XPath valida. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"'%s' non è un'espressione JSONPath valida. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"'%s' non è un'espressione jq valida. (%s)\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"Valore vuoto non consentito.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"Valore non valido.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"Gruppo / Etichetta\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"Monitora\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"Processore\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"Modifica > Monitora\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"Metodo di recupero\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"Corpo notifica\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"Formato notifica\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"Titolo notifica\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"Lista URL notifiche\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"Processore - Cosa vuoi ottenere?\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"Fuso orario predefinito per pianificazione controlli\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"Secondi di attesa prima di estrarre il testo\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"Deve contenere uno o più secondi\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"Carica file .xlsx\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"Deve essere un file .xlsx!\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"Mappatura file\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"Operazione\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"Selettore\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"valore\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"Intervallo tra controlli\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"Usa impostazioni globali per intervallo controlli e pianificazione.\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"Filtri CSS/JSONPath/JQ/XPath\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"Rimuovi elementi\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"Estrai testo\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"Titolo\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"Ignora righe contenenti\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"Corpo richiesta\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"Metodo richiesta\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"Ignora codici stato (elabora codici non-2xx come normali)\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"Attiva solo quando appaiono righe uniche in tutta la cronologia\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"Ordina testo alfabeticamente\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"Rimuovi righe ignorate\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"Rimuovi spazi prima e dopo il testo\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"Righe aggiunte\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"Righe sostituite/modificate\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"Righe rimosse\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"Trigger parole chiave - Attiva/attendi testo\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"Blocca rilevamento modifiche quando il testo corrisponde\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"Esegui JavaScript prima del rilevamento\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"Salva\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"Proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"Invia notifica quando il filtro non viene più trovato nella pagina\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"Disattivato\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"Attivo\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"Notifiche\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"Allega screenshot alla notifica (dove possibile)\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"Corrisponde\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"Corrisponde a tutti i seguenti\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"Corrisponde a uno qualsiasi dei seguenti\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"Usa <title> pagina nell'elenco\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"Il corpo deve essere vuoto quando il metodo è impostato su GET\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"Configurazione sintassi template non valida: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"Sintassi template non valida: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"Nome\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"URL Proxy\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"Gli URL proxy devono iniziare con http://, https:// o socks5://\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"URL connessione browser\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"Gli URL browser devono iniziare con wss:// o ws://\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"Richieste in chiaro\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Richieste Chrome\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"Proxy predefinito\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"Jitter casuale secondi ± controllo\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"Numero di worker di recupero\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"Deve essere tra 1 e 50\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"Timeout richieste in secondi\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"Deve essere tra 1 e 999\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"Override User-Agent predefiniti\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"Sono richiesti sia un nome che un URL proxy.\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"Apri pagina 'Cronologia' in una nuova scheda\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"Aggiornamenti UI in tempo reale attivi\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"Favicon attive\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"Usa <title> pagina nell'elenco osservati\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"Controllo sicurezza token API attivo\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"URL base notifiche\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"Tratta pagine vuote come modifica?\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"Ignora testo\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"Ignora spazi\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"Deve essere tra 0 e 100\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"Password\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"Dimensione paginatore\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"Deve essere almeno zero (disabilitato)\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"Formato contenuto RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"Corpo <description> RSS costruito da\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"Rimuovi password\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"Renderizza contenuto tag anchor\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"Consenti accesso anonimo alla cronologia quando la password è attiva\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"Nascondi osservazioni silenziate dal feed RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"Abilita modalità lettore RSS \"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"Numero di modifiche da mostrare nel feed RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"Deve contenere zero o più tentativi\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"Numero di volte che il filtro può mancare prima di inviare notifica\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"RegEx da estrarre\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"Estrai come CSV\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"Valore riquadro di selezione troppo lungo\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"Il riquadro deve essere nel formato: x,y,larghezza,altezza (solo numeri interi)\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"I valori del riquadro devono essere non negativi\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"I valori del riquadro sono troppo grandi\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"Percentuale minima di modifica\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"Sensibilità differenza pixel\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"Usa predefinito globale\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"Riquadro di selezione\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"Modalità selezione\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"Valore modalità selezione troppo lungo\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"Confronto screenshot\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"Rilevamento modifiche screenshot visivi\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"Confronta screenshot con algoritmo OpenCV veloce, 10-100x più veloce di SSIM\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"Solo disponibile (Esaurito -> Disponibile)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"Qualsiasi cambio disponibilità\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"Disattivo, non seguire disponibilità/rifornimenti\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"Prezzo minimo per attivare notifica\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"Nessun limite\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"Prezzo massimo per attivare notifica\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"Soglia in %% per modifiche prezzo dal prezzo originale\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"Segui modifiche prezzo\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"Rilevamento disponibilità e prezzi per pagine con UN SINGOLO prodotto\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"Rileva se il prodotto torna disponibile\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"Modifiche testo/HTML, JSON e PDF\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"Rileva tutte le modifiche di testo possibili\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"Errore nel recupero metadati per {}\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"Protocollo non consentito o formato URL non valido\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"Corpo per tutte le notifiche — Puoi usare\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"L'UUID del monitor.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"Gruppo / Etichetta\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"e\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"URL di notifica AppRise\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"Usa\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"del testo di notifica, incluso il titolo.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"Invia notifica di test\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"Log di debug delle notifiche\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"Elaborazione..\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"Titolo per tutte le notifiche\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"per esempio -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"Formato per tutte le notifiche\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"Azioni\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"nel\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"file\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"Limiti orari\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"Fine settimana\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"Ripristina\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"Vuoi usare una pianificazione oraria?\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"Testo trigger\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"Testo ignorato\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"Nessuna rilevazione se questo testo esiste.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"Testo bloccato\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"Cerca, o usa il tasto Alt+S\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"Seleziona Lingua\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"Rileva automaticamente dal browser\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"Il supporto linguistico è in versione beta, aiutaci a migliorare aprendo una PR su GitHub con eventuali aggiornamenti.\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"Cerca\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"in\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"informazioni qui\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"Accedi\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"GRUPPI\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"IMPOSTAZIONI\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"IMPORTA\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"Riattiva notifiche\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"Disattiva notifiche\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"MODIFICA\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"ESCI\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"Cambia Modalità Chiaro/Scuro\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"Cambia modalità chiaro/scuro\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"Cambia Lingua\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"Cambia lingua\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"Sì\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"No\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"Impostazioni principali\"\n\n#~ msgid \"Actions\"\n#~ msgstr \"Condizioni\"\n\n#~ msgid \"in the\"\n#~ msgstr \"Silenzia\"\n\n#~ msgid \"file\"\n#~ msgstr \"Titolo\"\n\n#~ msgid \"Schedule time limits\"\n#~ msgstr \"Tempo di ricontrollo (minuti)\"\n\n#~ msgid \"Weekends\"\n#~ msgstr \"Settimane\"\n\n#~ msgid \"Reset\"\n#~ msgstr \"Richiesta\"\n\n#~ msgid \"Want to use a time schedule?\"\n#~ msgstr \"Usa pianificazione oraria\"\n\n#~ msgid \"Triggered text\"\n#~ msgstr \"Ignora testo\"\n\n#~ msgid \"Ignored text\"\n#~ msgstr \"Ignora testo\"\n\n#~ msgid \"No change-detection will occur because this text exists.\"\n#~ msgstr \"Blocca rilevamento modifiche quando il testo corrisponde\"\n\n#~ msgid \"Blocked text\"\n#~ msgstr \"Ignora testo\"\n\n#~ msgid \"Search\"\n#~ msgstr \"Ricerca in corso\"\n\n#~ msgid \"in\"\n#~ msgstr \"Info\"\n\n#~ msgid \"Watch List\"\n#~ msgstr \"Lista Monitoraggi\"\n\n#~ msgid \"Watches\"\n#~ msgstr \"Monitoraggi\"\n\n#~ msgid \"Queue\"\n#~ msgstr \"In coda\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/translations/ko/LC_MESSAGES/messages.po",
    "content": "# Korean translations for PROJECT.\n# Copyright (C) 2026 ORGANIZATION\n# This file is distributed under the same license as the PROJECT project.\n# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PROJECT VERSION\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-02 11:40+0100\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language: ko\\n\"\n\"Language-Team: ko <LL@li.org>\\n\"\n\"Plural-Forms: nplurals=1; plural=0;\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"백업이 실행 중입니다!\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"MB\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"백업을 찾을 수 없습니다.\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"백업 생성\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"백업 삭제\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"URL 목록\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX 및 와체테\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"예:\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"유효성 검사를 통과하지 못한 URL은 텍스트 영역에 유지됩니다.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"이것은\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"실험적인\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"지원되는 필드는\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"나머지 (포함\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"무시됩니다.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"수출하는 방법?\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"필요한 경우 기본 가져오기 프로그램을 Chrome으로 설정하세요.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"사용자 정의 열 및 데이터 유형 매핑 표\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"맞춤 매핑\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"파일 매핑 유형.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"열 #\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"유형\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"없음\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"CSS/xPath 필터\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"감시 그룹/태그\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"재확인 시간(분)\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"수입\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"경고: 워커 수({})가 사용 가능한 CPU 코어 수({})에 근접하거나 초과합니다\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"설정이 업데이트되었습니다.\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"모든 알림 음소거됨.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"모든 알림 음소거 해제됨.\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"알림 디버그 로그\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"일반적인\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"가져오기\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"글로벌 필터\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"UI 옵션\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"백업\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"시간 및 날짜\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"보안 문자 및 프록시\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"정보\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"모든 시계의 기본 재확인 시간, 현재 시스템 최소값은 다음과 같습니다.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"추가 정보\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"CSS/xPath 필터가 이만큼 연속으로 누락되면 알림 전송\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"설정:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"비밀번호 활성화 시 변경 기록 페이지 액세스 허용 (diff 페이지 공유에 유용)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"모든 모니터의 기본 프록시 선택\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"알림 링크의 토큰.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"여기서 더 읽기\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"사용\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"기초적인\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"그만큼\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"크롬/자바스크립트\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"이것은 기다릴 것이다\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"텍스트를 추출하기 몇 초 전입니다.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"현재 실행 중:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"작동 중\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"워커\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"활발히 처리 중\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"팁:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"Bright Data 및 Oxylabs 프록시를 사용하여 연결하세요. 여기에서 자세한 내용을 알아보세요.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"참고:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"무시됨\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"API 액세스 및 예제\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"API 키\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Chrome 확장 프로그램\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Chrome 웹스토어\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"팁\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"파이썬 버전:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"활성화된 플러그인:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"활성화된 플러그인이 없습니다.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"뒤로\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"기록 지우기/재설정\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"태그 추가됨\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"업데이트됨\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"필터 및 트리거\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"설정\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"추가됨\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"기존 시계 구성에 적용됩니다.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"텍스트 필터링\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"주의해서 사용하세요!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"이로 인해 이메일 저장 할당량이 쉽게 채워지거나 다른 저장 공간이 넘치게 됩니다.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"로그아웃\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"로그아웃\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"있다\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"시스템 전체 알림 URL이 활성화되었습니다.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"이 양식은 이 시계에 대해서만 알림 설정을 재정의합니다.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"여기의 빈 알림 URL 목록은 여전히 ​​알림을 보냅니다.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"시스템 기본값 사용\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"새 조직 태그 추가\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"그룹 / 태그\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"# 모니터\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"태그/라벨 이름\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"구성된 그룹/태그 없음\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"편집하다\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"재확인\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"그룹을 삭제하시겠습니까?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"삭제\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"태그 삭제 및 제거\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"그룹을 연결 해제하시겠습니까?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"풀리다\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"태그는 유지하되 시계 연결을 해제하세요.\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"이 시계에 대한 RSS 피드\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"{}개 모니터 일시정지됨\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"{}개 모니터 음소거됨\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"{}개 모니터가 기본 알림 설정 사용\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"모니터를 찾을 수 없음\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"모니터 {} 스냅샷 기록 삭제됨\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"잘못된 확인 텍스트.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"삭제됨.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"{}개 모니터 대기열 추가 ({}개 이미 대기 중).\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"{}개의 감시 항목을 재확인 대기열에 추가했습니다.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"백그라운드에서 모니터 재확인 대기열 추가 중...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"브라우저 자동 감지로 언어 설정\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"모니터가 업데이트되었습니다.\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"당신은\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"백업\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"먼저 링크하세요.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"확인 텍스트\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"단어를 입력하세요\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"분명한\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"당신이 이해했는지 확인하기 위해.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"기록 지우기\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"취소\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"차이점을 이미지로 공유\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"이미지로 공유\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"일치하는 줄을 무시하세요.\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"숫자를 제외하고 일치하는 줄을 무시합니다.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"에서\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"에게\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"단어\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"줄\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"공백 무시\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"동일/변경되지 않음\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"제거됨\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"추가됨\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"교체됨\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"건반:\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"시사\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"다음\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"다음 차이점으로 이동\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"도약\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"오류 텍스트\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"오류 스크린샷\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"텍스트\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"현재 스크린샷\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"데이터 추출\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"초 전.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"초 전\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"가장 최근 요청의 현재 오류 스크린샷\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"전문가 팁: 활성화할 수 있습니다.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"설정\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"단일 스냅샷으로 이동\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"공유하거나 무시 목록에 추가할 텍스트를 강조 표시합니다.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"가장 최근 요청의 현재 스크린샷\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"아직 스크린샷이 없습니다! 페이지를 다시 확인해 보세요.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"스크린샷을 찍으려면 Playwright/WebDriver를 활성화해야 합니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"요구\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"브라우저 단계\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"시각적 필터 선택기\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"정황\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"통계\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"일부 사이트에서는 JavaScript를 사용하여 콘텐츠를 생성합니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"Chrome/WebDriver Fetcher를 사용하세요.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"URL에서 변수가 지원됩니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"여기에 도움말과 예시가 있습니다\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"그룹/태그 이름\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"각 확인 사이의 간격/시간입니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"모두 다시 확인하세요\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"이 시계에 대한 RSS 피드\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"현재 전역 기본 설정 사용\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"고급 옵션 표시\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"여기에 더 많은 도움말과 예시가 있습니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"요청 본문에서 변수가 지원됩니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"요청 헤더 값에서 변수가 지원됩니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"알리다! 추가 헤더 파일이 발견되어 이 시계에 추가됩니다!\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"데이터 디렉터리의 파일에서도 헤더를 읽을 수 있습니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"여기서 더 읽어보세요\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"Selenium 브라우저에서는 지원되지 않습니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"텍스트 찾기 켜기\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"잠시 기다려 주십시오. 첫 번째 브라우저 단계를 로드하는 데 약간의 시간이 걸릴 수 있습니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"시작하려면 여기를 클릭하세요\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"브라우저가 연결되는 데 10~15초 정도 기다려 주십시오.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"시각적 선택기 데이터가 준비되지 않았습니다. 시계를 한 번 이상 확인해야 합니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"대화형 Javascript를 지원하는 것입니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"당신은\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"가져오기 방법 설정\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"다음에 대한 빠른 튜토리얼을 읽어보세요.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"여기에서 조건부 웹페이지 변경 사항을 사용합니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"시사\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"전문가 팁:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"미리보기 페이지를 사용하여 강조 표시된 필터와 트리거를 확인하세요.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"트리거/무시/차단/추출을 다음으로 제한합니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"대신에\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"대사\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"예를 들어.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"덧셈\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"따라서 항상 선택하는 것이 좋습니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"새로운 콘텐츠에 관심이 있을 때.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"콘텐츠가 목록에서 단순히 이동되는 경우에도\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"활성화하는 것을 고려해보세요\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"고유한 줄이 나타날 때만 트리거됩니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"고유 라인 확인\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"아래에.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"각 텍스트 줄 앞과 뒤의 공백을 제거하세요.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"로드 중...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"시각적 선택 도구를 사용하면 다음을 선택할 수 있습니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"텍스트\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"정지시키다\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"Shift+클릭\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"여러 항목을 선택하려면\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"선택 모드:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"요소별로 선택\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"그리기 영역\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"선택 취소\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"잠시만 기다려 주세요. 스크린샷과 요소 정보를 가져오는 중입니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"현재:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"Javascript와 스크린샷을 지원하는 것입니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"수표 수\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"연속적인 필터 실패\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"역사\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"마지막 가져오기 기간\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"알림 경고 수\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"서버 유형 응답\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"최신 HTML 스냅샷 다운로드\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"시계를 삭제하시겠습니까?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"정말로 다음의 시계를 삭제하시겠습니까?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"이 작업은 취소할 수 없습니다.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"기록 지우기\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"정말로 다음의 기록을 모두 지우시겠습니까?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"기록 지우기\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"복제 및 편집\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"타임스탬프 선택\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"가다\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"가장 최근 요청의 현재 오류 스크린샷\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"총 <b>{total}</b>개 중 <b>{start} - {end}</b>개 {record_name} 표시\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"기록\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"추가 정보\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"새로운 웹 페이지 변경 감지 감시 추가\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"이 URL 모니터!\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"편집 후 모니터\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"정지시키다\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"일시정지 해제\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"무음\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"음소거 해제\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"꼬리표\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"본 것으로 표시\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"기본 알림 사용\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"오류 지우기\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"기록 지우기\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"좋아요\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"기록 지우기/재설정\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"시계를 삭제하시겠습니까?\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"대기열 크기\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"수색\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"모두\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"웹사이트\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"재입고 및 가격\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"체크됨\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"마지막\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"변경됨\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"구성된 웹사이트 시계가 없습니다. 위 상자에 URL을 추가하세요. 또는\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"목록 가져오기\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"재입고 및 가격 감지\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"재고 있음\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"재고가 없습니다\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"가격\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"정보 없음\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"지금 확인 중\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"대기 중\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"기록\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"시사\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"오류가 있음\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"모두 본 것으로 표시\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"'%(title)s'에서 모두 본 것으로 표시\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"읽히지 않는\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"모두 다시 확인하세요\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"'%(title)s'에서\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"아직 아님\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"초\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"잘못된 비밀번호\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"시간 형식이 잘못되었습니다. HH:MM을 사용하세요.\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"유효한 시간대 이름이 아닙니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"설정 안 됨\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"시작 시간\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"실행 시간\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"시간 스케줄러 사용\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"실행할 선택적 시간대\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"월요일\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"화요일\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"수요일\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"목요일\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"금요일\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"토요일\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"일요일\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"주\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"0초 이상을 포함해야 합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"날\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"시간\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"분\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"초\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"알림 URL을 사용하는 경우 알림 본문 및 제목이 필요합니다.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"'%s'은(는) 유효한 AppRise URL이 아닙니다.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"RegEx '%s'은(는) 유효한 정규식이 아닙니다.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"'%s'은(는) 유효한 XPath 표현식이 아닙니다. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"'%s'은(는) 유효한 JSONPath 표현식이 아닙니다. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"'%s'은(는) 유효한 jq 표현식이 아닙니다. (%s)\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"빈 값은 허용되지 않습니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"값이 잘못되었습니다.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"그룹 / 태그\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"모니터\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"프로세서\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"편집 > 모니터\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"가져오기 방법\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"알림 본문\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"알림 형식\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"알림 제목\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"알림 URL 목록\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"프로세서 - 무엇을 달성하고 싶나요?\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"시계 확인 스케줄러의 기본 시간대\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"텍스트 추출 전 대기 시간(초)\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"1초 이상을 포함해야 합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \".xlsx 파일 업로드\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \".xlsx 파일이어야 합니다!\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"파일 매핑\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"작업\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"선택자\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"값\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"확인 간격\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"확인과 스케줄러 사이의 시간에 대한 전역 설정을 사용합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"CSS/JSONPath/JQ/XPath 필터\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"요소 제거\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"텍스트 추출\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"제목\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"포함된 줄 무시\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"요청 본문\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"요청 방법\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"상태 코드 무시(2xx가 아닌 상태 코드를 정상적으로 처리)\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"모든 기록에서 고유한 줄이 나타날 때만 트리거\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"중복된 텍스트 줄 제거\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"텍스트를 알파벳순으로 정렬\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"무시된 줄 제거\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"텍스트 앞뒤 공백 제거\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"추가된 줄\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"교체/변경된 라인\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"삭제된 줄\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"키워드 트리거 - 텍스트 트리거/대기\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"텍스트가 일치하는 동안 변경 감지 차단\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"변경 감지 전에 JavaScript 실행\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"구하다\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"프록시\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"페이지에서 필터를 더 이상 찾을 수 없으면 알림 보내기\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"음소거됨\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"켜짐\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"알림\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"알림에 스크린샷 첨부(가능한 경우)\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"# 시계\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"다음을 모두 일치시키세요.\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"다음 중 하나와 일치\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"목록의 <제목> 페이지 사용\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"요청 방법이 GET으로 설정된 경우 본문이 비어 있어야 합니다.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"잘못된 템플릿 구문 구성: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"잘못된 템플릿 구문: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"이름\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"프록시 URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"프록시 URL은 http://, https:// 또는 sock5://로 시작해야 합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"브라우저 연결 URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"브라우저 URL은 wss:// 또는 ws://로 시작해야 합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"일반 텍스트 요청\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Chrome 요청\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"기본 프록시\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"랜덤 지터 초 ± 확인\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"가져오기 작업자 수\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"1에서 50 사이여야 합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"요청 시간 초과(초)\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"1에서 999 사이여야 합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"기본 사용자 에이전트 재정의\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"이름과 프록시 URL이 모두 필요합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"새 탭에서 '기록' 페이지 열기\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"실시간 UI 업데이트 활성화됨\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"파비콘 활성화됨\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"시청 개요 목록에서 <제목> 페이지를 사용하세요.\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"API 액세스 토큰 보안 확인이 활성화되었습니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"알림 기본 URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"빈 페이지를 변경 사항으로 처리하시겠습니까?\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"텍스트 무시\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"공백 무시\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"0에서 100 사이여야 합니다.\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"비밀번호\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"호출기 크기\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"0 이상이어야 합니다(비활성화됨).\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"RSS 콘텐츠 형식\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"RSS <설명> 본문은 다음에서 작성되었습니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"비밀번호 제거\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"앵커 태그 콘텐츠 렌더링\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"비밀번호가 활성화되면 시청 기록 페이지에 대한 익명 액세스를 허용합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"RSS 피드에서 음소거된 시계 숨기기\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"RSS 리더 모드 활성화\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"시계 RSS 피드에 표시할 변경 사항 수\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"0개 이상의 시도를 포함해야 합니다.\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"알림을 보내기 전에 필터가 누락될 수 있는 횟수\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"추출할 RegEx\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"CSV로 추출\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"경계 상자 값이 너무 깁니다.\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"경계 상자는 x,y,너비,높이(정수만) 형식이어야 합니다.\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"경계 상자 값은 음수가 아니어야 합니다.\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"경계 상자 값이 너무 큽니다.\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"최소 변경 비율\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"픽셀 차이 감도\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"전역 기본값 사용\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"경계 상자\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"선택 모드\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"선택 모드 값이 너무 깁니다.\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"스크린샷 비교\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"시각적/이미지 스크린샷 변경 감지\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"재입고 감지\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"재고만 있음 (품절 -> 재고만 있음)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"이용 가능 여부 변경\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"끄기, 재고 여부를 따르지 않음/재입고\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"알림을 실행할 가격 미만\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"제한 없음\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"가격보다 높으면 알림이 실행됩니다.\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"원래 가격 이후 가격 변동에 대한 기준점(%)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"0에서 100 사이여야 합니다.\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"가격 변동을 따르세요\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"재입고 및 가격 감지\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"단일 제품이 포함된 페이지의 재입고 및 가격 감지\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"제품이 다시 재고로 돌아왔는지 감지합니다.\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"웹페이지 텍스트/HTML, JSON 및 PDF 변경\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"가능한 경우 모든 텍스트 변경 사항을 감지합니다.\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"모든 알림 본문 — 사용 가능:\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"설명\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"모니터의 UUID.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"모니터 그룹 / 태그\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"및\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"더보기\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"AppRise 알림 URL\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"사용\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"고급 도움말 표시\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"제목 포함 알림 텍스트.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"더 많은 도움말\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"테스트 알림 보내기\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"알림 디버그 로그\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"처리 중..\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"모든 알림 제목\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"예 -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"모든 알림 형식\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"작업\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"필요할 수 있음:\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"에서\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"파일\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"일정 시간 제한\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"주말\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"재설정\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"스케줄러 사용 도움말\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"시간 스케줄을 사용하시겠습니까?\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"무시된 텍스트\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"이 텍스트 존재 시 변경 감지 안 함.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"검색 또는 Alt+S 키 사용\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"실시간 업데이트 오프라인\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"언어 선택\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"브라우저에서 자동 감지\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"검색\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"내\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"여기 정보\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"로그인\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"여러 떼\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"설정\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"가져오기\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"알림 음소거 해제\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"알림 음소거\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"알림 음소거됨 - 클릭하여 해제\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"편집하다\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"로그아웃\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"밝은/어두운 모드 전환\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"밝은/어두운 모드 전환\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"언어 변경\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"언어 변경\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"예\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"아니오\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"기본 설정\"\n\n#~ msgid \"not set\"\n#~ msgstr \"아직 아님\"\n\n#~ msgid \"Start At\"\n#~ msgstr \"설정\"\n\n#~ msgid \"Run duration\"\n#~ msgstr \"정보 없음\"\n\n#~ msgid \"Hours\"\n#~ msgstr \"비밀번호\"\n\n#~ msgid \"Minutes\"\n#~ msgstr \"무음\"\n\n#~ msgid \"Seconds\"\n#~ msgstr \"초\"\n\n#~ msgid \"Fetch Method\"\n#~ msgstr \"가져오기 방법 설정\"\n\n#~ msgid \"Notification Body\"\n#~ msgstr \"정보 없음\"\n\n#~ msgid \"Notification format\"\n#~ msgstr \"정보 없음\"\n\n#~ msgid \"Notification Title\"\n#~ msgstr \"정보 없음\"\n\n#~ msgid \"Notification URL List\"\n#~ msgstr \"정보 없음\"\n\n#~ msgid \"Wait seconds before extracting text\"\n#~ msgstr \"텍스트를 추출하기 몇 초 전입니다.\"\n\n#~ msgid \"URLs\"\n#~ msgstr \"URL\"\n\n#~ msgid \"File mapping\"\n#~ msgstr \"파일 매핑 유형.\"\n\n#~ msgid \"Operation\"\n#~ msgstr \"UI 옵션\"\n\n#~ msgid \"Selector\"\n#~ msgstr \"선택 모드:\"\n\n#~ msgid \"value\"\n#~ msgstr \"정지시키다\"\n\n#~ msgid \"CSS/JSONPath/JQ/XPath Filters\"\n#~ msgstr \"CSS/xPath 필터\"\n\n#~ msgid \"Remove elements\"\n#~ msgstr \"제거됨\"\n\n#~ msgid \"Extract text\"\n#~ msgstr \"데이터 추출\"\n\n#~ msgid \"Ignore lines containing\"\n#~ msgstr \"일치하는 줄을 무시하세요.\"\n\n#~ msgid \"Request body\"\n#~ msgstr \"요구\"\n\n#~ msgid \"Request method\"\n#~ msgstr \"요구\"\n\n#~ msgid \"Only trigger when unique lines appear in all history\"\n#~ msgstr \"고유한 줄이 나타날 때만 트리거됩니다.\"\n\n#~ msgid \"Trim whitespace before and after text\"\n#~ msgstr \"각 텍스트 줄 앞과 뒤의 공백을 제거하세요.\"\n\n#~ msgid \"Added lines\"\n#~ msgstr \"로그인\"\n\n#~ msgid \"Removed lines\"\n#~ msgstr \"제거됨\"\n\n#~ msgid \"Realtime UI Updates Enabled\"\n#~ msgstr \"실시간 업데이트 오프라인\"\n\n#~ msgid \"Favicons Enabled\"\n#~ msgstr \"활성화하는 것을 고려해보세요\"\n\n#~ msgid \"Ignore Text\"\n#~ msgstr \"오류 텍스트\"\n\n#~ msgid \"Ignore whitespace\"\n#~ msgstr \"공백 무시\"\n\n#~ msgid \"Extract as CSV\"\n#~ msgstr \"데이터 추출\"\n\n#~ msgid \"Use global default\"\n#~ msgstr \"시스템 기본값 사용\"\n\n#~ msgid \"Selection Mode\"\n#~ msgstr \"선택 모드:\"\n\n#~ msgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\n#~ msgstr \"SSIM보다 10~100배 빠른 빠른 OpenCV 알고리즘을 사용하여 스크린샷을 비교합니다.\"\n\n#~ msgid \"Restock & Price Detection\"\n#~ msgstr \"재입고 및 가격\"\n\n#~ msgid \"Actions\"\n#~ msgstr \"정황\"\n\n#~ msgid \"You may need to\"\n#~ msgstr \"당신은\"\n\n#~ msgid \"in the\"\n#~ msgstr \"그만큼\"\n\n#~ msgid \"file\"\n#~ msgstr \"제목\"\n\n#~ msgid \"Schedule time limits\"\n#~ msgstr \"재확인 시간(분)\"\n\n#~ msgid \"Weekends\"\n#~ msgstr \"주\"\n\n#~ msgid \"Reset\"\n#~ msgstr \"요구\"\n\n#~ msgid \"More help and examples about using the scheduler\"\n#~ msgstr \"여기에 더 많은 도움말과 예시가 있습니다.\"\n\n#~ msgid \"Want to use a time schedule?\"\n#~ msgstr \"시간 스케줄러 사용\"\n\n#~ msgid \"Triggered text\"\n#~ msgstr \"오류 텍스트\"\n\n#~ msgid \"Ignored text\"\n#~ msgstr \"오류 텍스트\"\n\n#~ msgid \"No change-detection will occur because this text exists.\"\n#~ msgstr \"텍스트가 일치하는 동안 변경 감지 차단\"\n\n#~ msgid \"Blocked text\"\n#~ msgstr \"오류 텍스트\"\n\n#~ msgid \"Search\"\n#~ msgstr \"수색\"\n\n#~ msgid \"in\"\n#~ msgstr \"추가 정보\"\n\n#~ msgid \"Visual\"\n#~ msgstr \"시각적\"\n\n#~ msgid \"Restock\"\n#~ msgstr \"재입고\"\n\n#~ msgid \"Watch List\"\n#~ msgstr \"모니터 목록\"\n\n#~ msgid \"Watches\"\n#~ msgstr \"모니터\"\n\n#~ msgid \"Queue\"\n#~ msgstr \"대기 중\"\n\n#~ msgid \"Cleared snapshot history for all watches\"\n#~ msgstr \"기록 지우기/재설정\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"공유 가능한 링크 만들기\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"팁: '공유' 시계를 추가할 수도 있습니다.\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/translations/messages.pot",
    "content": "# Translations template for changedetection.io.\n# Copyright (C) 2026 ORGANIZATION\n# This file is distributed under the same license as the changedetection.io project.\n# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.\n#\n#, fuzzy\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: changedetection.io 0.53.6\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language-Team: LANGUAGE <LL@li.org>\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches deleted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches paused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches unpaused\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches updated\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches un-muted\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches errors cleared\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"{} watches were tagged\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Switched to mode - {}.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/translations/uk/LC_MESSAGES/messages.po",
    "content": "# Ukrainian translations for changedetection.io\n# Copyright (C) 2026 changedetection.io\n# This file is distributed under the same license as the changedetection.io project.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: changedetection.io\\n\"\n\"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\\n\"\n\"POT-Creation-Date: 2026-02-05 17:47+0100\\n\"\n\"PO-Revision-Date: 2026-02-19 12:30+0100\\n\"\n\"Last-Translator: \\n\"\n\"Language: uk\\n\"\n\"Language-Team: Ukrainian\\n\"\n\"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.17.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"Резервне копіювання вже виконується, перевірте через кілька хвилин\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"Досягнуто максимальної кількості резервних копій, будь ласка, видаліть старі\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"Резервна копія створюється у фоновому режимі, перевірте через кілька хвилин.\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"Резервні копії було видалено.\"\n\n#: changedetectionio/blueprint/backups/templates/overview.html changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"Резервні копії\"\n\n#: changedetectionio/blueprint/backups/templates/overview.html\nmsgid \"A backup is running!\"\nmsgstr \"Виконується резервне копіювання!\"\n\n#: changedetectionio/blueprint/backups/templates/overview.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"Тут ви можете завантажити або створити нову резервну копію. Коли створення завершиться, вона з'явиться у списку нижче.\"\n\n#: changedetectionio/blueprint/backups/templates/overview.html\nmsgid \"Mb\"\nmsgstr \"Мб\"\n\n#: changedetectionio/blueprint/backups/templates/overview.html\nmsgid \"No backups found.\"\nmsgstr \"Резервних копій не знайдено.\"\n\n#: changedetectionio/blueprint/backups/templates/overview.html\nmsgid \"Create backup\"\nmsgstr \"Створити резервну копію\"\n\n#: changedetectionio/blueprint/backups/templates/overview.html\nmsgid \"Remove backups\"\nmsgstr \"Видалити резервні копії\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"Імпортуються перші 5000 URL з вашого списку, решту можна імпортувати повторно.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"{} Імпортовано зі списку за {:.2f}с, {} Пропущено.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"Не вдалося прочитати JSON-файл, можливо, він пошкоджений?\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"Структура JSON виглядає некоректною, можливо, файл пошкоджений?\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"{} Імпортовано з Distill.io за {:.2f}с, {} Пропущено.\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"Не вдалося прочитати файл експорту XLSX, щось не так із файлом?\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"Помилка обробки рядка {}, значення URL некоректне, рядок пропущено.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"Помилка обробки рядка {}, перевірте правильність типів даних у клітинках, рядок пропущено.\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"{} імпортовано з Wachete .xlsx за {:.2f}с\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"{} імпортовано з власного .xlsx за {:.2f}с\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"Список URL\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX та Wachete\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"Введіть по одному URL у рядок, опціонально додайте теги для кожного URL через пробіл, розділяючи їх комами (,):\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"Приклад:\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"URL, що не пройшли перевірку, залишаться у текстовому полі.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"Скопіюйте та вставте вміст файлу експорту з Distill.io (файл JSON).\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"Це\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"експериментальна функція\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"підтримувані поля:\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"решта (включно з\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"ігноруються.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"Як експортувати?\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"Переконайтеся, що встановлено Chrome як завантажувач за замовчуванням, якщо це необхідно.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"Таблиця зіставлення користувацьких стовпців і типів даних для\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"Власне зіставлення\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"Тип зіставлення файлу.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"Стовпець №\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"Тип\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"немає\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"Фільтр CSS/xPath\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"Група / Ім'я тегу(ів)\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"Час перевірки (хвилини)\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"Імпорт\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"Захист паролем вимкнено.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"Увага: Кількість воркерів ({}) наближається до кількості доступних ядер процесора або перевищує її ({})\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"Кількість воркерів скориговано: {}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"Динамічне налаштування воркерів не підтримується для синхронних процесів\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"Помилка налаштування воркерів: {}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"Захист паролем увімкнено.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"Налаштування оновлено.\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"Сталася помилка, дивіться подробиці нижче.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"API-ключ було згенеровано заново.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"Автоматичне планування призупинено — перевірки не додаватимуться в чергу.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"Автоматичне планування відновлено — перевірки виконуватимуться у звичайному режимі.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"Усі сповіщення вимкнено.\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"Усі сповіщення увімкнено.\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"Журнал налагодження сповіщень\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"Загальні\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"Отримання даних\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"Глобальні фільтри\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"Налаштування інтерфейсу\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"Час і Дата\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"CAPTCHA та Проксі\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"Інфо\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"Час перевірки за замовчуванням для всіх завдань (поточний системний мінімум:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"seconds\"\nmsgstr \"секунд)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"детальніше\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"Після стількох послідовних відсутностей CSS/xPath фільтра надсилати сповіщення\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"Встановіть\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"щоб вимкнути\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"Обмежити історію знімків для кожного завдання цією кількістю записів.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"Залиште порожнім, щоб вимкнути (без обмежень)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"Захист паролем вашого застосунку changedetection.io.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"Пароль заблоковано.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"Дозволити доступ до сторінки історії змін при увімкненому паролі (корисно для поширення посилань на різницю)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"Чи вважати зміною, якщо запит не повертає вмісту або HTML не містить тексту?\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"Виберіть проксі за замовчуванням для всіх завдань\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"Базовий URL, що використовується для\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"токена в посиланнях сповіщень.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"Значення за замовчуванням береться із системної змінної оточення\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"читати більше тут\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"метод (за замовчуванням), якщо сайтам не потрібен Javascript для відображення.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"Використовувати\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"Базовий\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"метод потребує мережевого підключення до запущеного сервера WebDriver+Chrome, заданого змінною оточення\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"Chrome/Javascript\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"\"\n\"Якщо сторінка не встигає повністю відобразитися (відсутній текст тощо), спробуйте збільшити час очікування.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"Очікування складе\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"секунд перед вилученням тексту.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"Кількість одночасних процесів (воркерів). Більше воркерів = швидше, але потребує більше пам'яті.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"Зараз запущено:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"активних\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"воркерів\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"в обробці\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"Приклад: 3 секунди випадкового джитера можуть запустити перевірку на 3 секунди раніше або пізніше\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"Для звичайних запитів (не Chrome): максимальний час очікування (тайм-аут) у секундах, 1-999.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"Застосовується до всіх запитів.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"Примітка: Проста зміна User-Agent часто не допомагає обійти захист від роботів, важливо враховувати\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"усі способи виявлення браузера\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"Порада:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"Підключення через проксі Bright Data та Oxylabs, дізнайтеся більше тут.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"Ігнорувати пробіли, табуляцію та переноси рядків під час виявлення змін.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"Примітка:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"Зміна цього параметра вплине на статус існуючих завдань, можливе спрацювання сповіщень.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"Відображати вміст тегів посилань. За замовчуванням вимкнено. Якщо увімкнено, посилання відображаються як\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"Зміна цього параметра може вплинути на вміст ваших завдань та викликати хибні спрацювання.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"Видалити HTML-елемент(и) за допомогою CSS та XPath селекторів перед перетворенням у текст.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"Не вставляйте сюди HTML, використовуйте лише CSS та XPath селектори\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"Додайте кілька елементів, CSS або XPath селекторів (по одному на рядок), щоб ігнорувати частини HTML.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"Примітка: Це застосовується глобально на додаток до правил конкретних завдань.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"Текст, що збігається, буде\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"проігноровано\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"у текстовому знімку (ви будете його бачити, але він не викличе сповіщення про зміну)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"Кожен рядок обробляється окремо; будь-який рядок, що збігається, буде проігноровано (видалено перед створенням контрольної суми)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"Підтримка регулярних виразів: обгорніть весь рядок у скісні риски\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"Зміна цього параметра вплине на контрольну суму порівняння, що може викликати спрацювання сповіщення\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"Видалити будь-який текст, вказаний у «Ігнорувати текст», із виводу (інакше він просто ігнорується під час перевірки змін)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"Доступ до API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"Керуйте changedetection.io через API. Детальніше про\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"доступ до API та приклади тут\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"Обмежити доступ до API за допомогою\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"заголовок - необхідний для роботи розширення Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"Ключ API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"копіювати\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"Перестворити ключ API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Розширення Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"Легко додавайте будь-яку веб-сторінку до вашої інсталяції changedetection.io прямо з Chrome.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"Крок 1\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"Встановіть розширення,\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"Крок 2\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"Перейдіть на цю сторінку,\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"Крок 3\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"Відкрийте розширення на панелі інструментів і натисніть\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"Синхронізувати доступ до API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"Спробуйте наше нове розширення для Chrome!\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"Іконка магазину Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Магазин Chrome\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"Максимальна кількість знімків історії для включення в RSS-стрічку конкретного завдання.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"Для відстеження інших RSS-каналів — при відстеженні RSS/Atom перетворювати їх на чистий текст для кращого виявлення змін.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"Ваша читалка підтримує HTML? Налаштуйте тут\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"'Системний за замовчуванням' для єдиного шаблону або використовуйте ваше 'Тіло сповіщення' як шаблон.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"Переконайтеся, що налаштування нижче вірні, вони використовуються для керування розкладом перевірок веб-сторінок.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"Час і дата UTC із сервера:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"Місцевий час і дата у браузері:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"Увімкніть, щоб відкривати сторінку різниці у новій вкладці. Якщо вимкнено, сторінка відкриється у поточній вкладці.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"Оновлення інтерфейсу в реальному часі увімкнено — (Потрібен перезапуск при зміні)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"Увімкнути або вимкнути значки (фавіконки) поруч зі списком завдань\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"Кількість елементів на сторінці у списку завдань, 0 для вимкнення.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"Порада\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"Проксі типу «Резидентні» та «Мобільні» можуть бути ефективнішими, ніж «Дата-центр», для заблокованих сайтів.\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"«Ім'я» буде використовуватися для вибору проксі в налаштуваннях завдання\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\"SOCKS5 проксі з аутентифікацією підтримуються лише завантажувачем 'звичайні запити', для інших завантажувачів \"\n\"необхідно додати IP до білого списку\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"Версія Python:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"Активні плагіни:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"Немає активних плагінів\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"Назад\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"Очистити історію знімків\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"Тег «{}» вже існує\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"Тег додано\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"Тег видалено, видалення із завдань виконується у фоновому режимі\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"Відв'язування тегу від завдань виконується у фоновому режимі\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"Усі теги видалено, очищення завдань виконується у фоновому режимі\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"Тег не знайдено\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"Оновлено\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"Фільтри та Тригери\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"Ці налаштування\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"додаються\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"до будь-яких існуючих конфігурацій завдань.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"Фільтрація тексту\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"Використовуйте з обережністю!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"Це може швидко переповнити вашу поштову скриньку або інші сховища.\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"Увага!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"Обережно!\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"Є\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"увімкнених системних URL сповіщень\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"ця форма перевизначить налаштування сповіщень лише для цього завдання\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"порожній список URL сповіщень тут не вимкне надсилання сповіщень (використовуватимуться системні).\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"Використовувати системні налаштування\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"Додати новий організаційний тег\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"Група / Тег відстеження\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"Групи дозволяють керувати фільтрами та сповіщеннями для кількох завдань під одним організаційним тегом.\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"К-ть завдань\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"Ім'я Тегу / Мітки\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"Організаційні теги/групи сайтів не налаштовані\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"Редагувати\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"Перевірити знову\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"Видалити групу?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>Ви впевнені, що бажаєте видалити групу <strong>%(title)s</strong>?</p><p>Цю дію неможливо скасувати.</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"Видалити\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"Видаляє тег повністю\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"Відв'язати групу?\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"\"\n\"<p>Ви впевнені, що хочете відв'язати всі завдання від групи <strong>%(title)s</strong>?</p><p>Тег збережеться, але \"\n\"завдання будуть видалені з нього.</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"Відв'язати\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"Зберегти тег, але відв'язати завдання\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"RSS-стрічка для цього завдання\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"{} завдань видалено\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"{} завдань призупинено\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"{} завдань відновлено\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"{} завдань оновлено\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"{} завдань заглушено (без сповіщень)\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"{} завдань увімкнено (зі сповіщеннями)\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"{} завдань поставлено в чергу на перевірку\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"Помилки очищено у {} завдань\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"{} завдань очищено/скинуто.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"Для {} завдань встановлено налаштування сповіщень за замовчуванням\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"{} завдань було позначено тегами\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"Завдання не знайдено\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"Очищено історію знімків для завдання {}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"Очищення історії запущено у фоновому режимі\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"Невірний текст підтвердження.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Marking watches as viewed in background...\"\nmsgstr \"Позначення завдань як переглянутих у фоновому режимі...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"Завдання з UUID {} не існує.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"Видалено.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"Клоновано, ви редагуєте нове завдання.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"Завдання вже в черзі або перевіряється.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"1 завдання додано в чергу на перевірку.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"Додано {} завдань у чергу ({} вже в черзі або виконуються).\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"Додано {} завдань у чергу на перевірку.\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"Додавання завдань у чергу виконується у фоновому режимі...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"Не вдалося поділитися, помилка зв'язку із сервером обміну - {}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"Мову встановлено на автовизначення з браузера\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"Історію для вказаного посилання не знайдено, невірне посилання?\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"Недостатньо історії (потрібно 2 знімки) для відображення різниці.\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"Немає завдань для редагування\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"Завдання з UUID {} не знайдено.\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"Переключено в режим - {}.\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"Не вдалося завантажити процесор '{}', можливо плагін відсутній. Будь ласка, виберіть інший процесор.\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"Не вдалося завантажити процесор '{}', можливо плагін відсутній.\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"Завдання оновлено — знято з паузи!\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"Завдання оновлено.\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"Попередній перегляд недоступний — перевірка не завершена або тригери не спрацювали\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"Це видалить історію версій (знімки) для ВСІХ завдань, але збереже ваш список URL!\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"Можливо, ви захочете спочатку використати\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"РЕЗЕРВНЕ КОПІЮВАННЯ\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \".\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"Текст підтвердження\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"Введіть слово\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"clear\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \", щоб підтвердити розуміння наслідків.\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"Очистити історію!\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"Скасувати\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"Поділитися різницею як зображенням\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"Поділитися як зображенням\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"Ігнорувати рядки, що збігаються з\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"Ігнорувати рядки, що збігаються з (виключаючи цифри)\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"Від\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"До\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"Слова\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"Рядки\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"Ігнорувати пробіли\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"Однакове/без змін\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"Видалено\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"Додано\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"Замінено\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"Клавіатура:\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"Попереднє\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"Наступне\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"Перейти до наступної розбіжності\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"Перейти\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"Текст помилки\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"Скріншот помилки\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"Текст\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"Поточний скріншот\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"Вилучити дані\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"секунд тому.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"секунд тому\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"Скріншот помилки з останнього запиту\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"Порада: Ви можете увімкнути\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"\\\"ділитися доступом при увімкненому паролі\\\"\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"у налаштуваннях.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"Перейти до одиночного знімка\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"Виділіть текст, щоб поділитися ним або додати до списку ігнорування.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"На даний момент порівняння виконується за текстом, а не графічно; доступний лише останній скріншот.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"Поточний скріншот з останнього запиту\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"Скріншот поки недоступний! Спробуйте перевірити сторінку ще раз.\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"Для скриншотів потрібен увімкнений Playwright/WebDriver\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"Запит\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"Кроки браузера\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"Візуальний селектор фільтра\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"Умови\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"Статистика\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"Деякі сайти використовують JavaScript для створення контенту, для цього вам слід\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"використовувати Chrome/WebDriver завантажувач\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"В URL підтримуються змінні\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"довідка та приклади тут\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"Ім'я організаційного тегу/групи, що використовується на головній сторінці\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"Автоматично використовує заголовок сторінки, якщо знайдено. Ви також можете вказати тут свою назву/опис.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"Інтервал часу між перевірками.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"\"\n\"Надсилає сповіщення, коли фільтр більше не видно на сторінці. Корисно, щоб дізнатися, що сторінка змінилася і \"\n\"ваш фільтр більше не працює.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"Залиште порожнім для використання системних налаштувань\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"метод (за замовчуванням), якщо сайту не потрібен Javascript для відображення.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"метод потребує підключення до сервера WebDriver+Chrome, заданого змінною 'WEBDRIVER_URL'.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"Перевірити/Сканувати всі\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"Виберіть проксі для цього завдання\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"Використовуються поточні глобальні налаштування\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"Показати розширені налаштування\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"Виконати цей код перед перевіркою змін, зручно для заповнення полів та інших дій\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"Більше довідки та прикладів тут\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"Змінні підтримуються в тілі запиту\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"Змінні підтримуються в заголовках запиту\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"Увага! Знайдено файл додаткових заголовків, їх буде додано до цього завдання!\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"Заголовки також можуть бути прочитані з файлу у вашій директорії даних\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"Читати більше тут\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"Не підтримується браузером Selenium\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"Увімкнути текстовий пошук\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"Будь ласка, зачекайте, перший крок браузера може зайняти деякий час...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"Натисніть тут для запуску\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"Будь ласка, зачекайте 10-15 секунд для підключення браузера.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"Натисніть \\\"Play\\\" для старту.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"Дані візуального селектора не готові, завдання має бути перевірено хоча б один раз.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"\"\n\"Вибачте, ця функція працює лише із завантажувачами, що підтримують інтерактивний Javascript (наразі лише на базі Playwright)\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"на той, що підтримує інтерактивний Javascript.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"Вам потрібно\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"Встановити метод завантаження\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"Використовуйте кнопку перевірки (✓), щоб протестувати умову на поточному знімку.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"Прочитайте короткий посібник про\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"використання умовних змін веб-сторінок тут\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"Активувати попередній перегляд\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"Поради:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"Використовуйте сторінку попереднього перегляду, щоб побачити підсвічування фільтрів і тригерів.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"Обмежити тригер/ігнор/блок/вилучення до;\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"Примітка: Залежно від довжини та схожості тексту, алгоритм може вважати це\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"замість\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"заміною\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"наприклад.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"додаванням\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"Тому завжди краще обирати\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"коли вас цікавить новий контент.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"Коли контент просто переміщується у списку, це також викличе\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"розгляньте увімкнення\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"Спрацьовувати лише при появі унікальних рядків\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"\"\n\"Корисно для сайтів, які просто переміщують контент, коли ви хочете знати лише про НОВИЙ контент. Порівнює \"\n\"нові рядки з усією історією цього завдання.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"Допомагає зменшити хибні спрацювання, спричинені перемішуванням рядків на сайті, використовуйте разом із\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"перевірка унікальних рядків\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"нижче.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"Видаляти пробіли на початку та в кінці кожного рядка тексту\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"Завантаження...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"Інструмент візуального вибору дозволяє вибрати\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"текстові\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"\"\n\"елементи, які будуть використовуватися для виявлення змін. Він автоматично заповнює фільтри в полі \"\n\"\\\"CSS/JSONPath/JQ/XPath Фільтри\\\" у вкладці\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \". Використовуйте\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"Shift+Клік\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"для вибору кількох елементів.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"Режим вибору:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"Вибір за елементом\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"Малювання області\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"Очистити вибір\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"Хвилинку, отримання скріншота та інформації про елементи...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"В даний час:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"Вибачте, ця функція працює лише із завантажувачами, що підтримують Javascript і скріншоти (наприклад, playwright).\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"на підтримуючий Javascript і скріншоти.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"Кількість перевірок\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"Послідовні помилки фільтра\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"Довжина історії\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"Тривалість останнього завантаження\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"Кількість надісланих сповіщень\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"Відповідь типу сервера\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"Завантажити останній знімок HTML\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"Видалити завдання?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"Ви впевнені, що хочете видалити завдання для:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"Цю дію неможливо скасувати.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"Очистити історію?\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"Ви впевнені, що хочете очистити всю історію для:\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"Це видалить усі знімки та попередні версії. Цю дію неможливо скасувати.\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"Очистити історію\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"Клонувати та Редагувати\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"Виберіть мітку часу\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"Перейти\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"Поточний скріншот з помилкою з останнього запиту\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"Скріншот вимагає завантажувача контенту (Sockpuppetbrowser, selenium тощо), який підтримує створення скріншотів.\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"Увага, URL {} вже існує\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"Завдання додано в стані 'Пауза', збереження зніме його з паузи.\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"Завдання додано.\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"відображено <b>{start} - {end}</b> {record_name}, всього <b>{total}</b>\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"записів\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"Changedetection.io може моніторити більше, ніж просто веб-сторінки! Дивіться наші плагіни!\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"Детальніше\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"Ви також можете додати 'спільні' завдання.\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"Додати нове завдання відстеження змін веб-сторінки\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"Відстежувати цей URL!\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"Спочатку редагувати, потім Відстежувати\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"Пауза\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"Старт\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"Заглушити\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"Увімкнути звук\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"Тег\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"Позначити як переглянуте\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"Вик. сповіщення за замовчуванням\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"Очистити помилки\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"Очистити історії\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>Ви впевнені, що хочете очистити історію для вибраних елементів?</p><p>Цю дію неможливо скасувати.</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"ОК\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"Очистити/скинути історію\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"Видалити завдання?\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>Ви впевнені, що хочете видалити вибрані завдання?</p><p>Цю дію неможливо скасувати.</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"Розмір черги\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"Пошук\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"Всі\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"Вебсайт\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"Наявність та Ціна\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"Перевірено\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"Останній\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"Змінено\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"Немає налаштованих завдань для відстеження змін, будь ласка, додайте URL у поле вище або\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"імпортуйте список\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"Визначення наявності та ціни\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"В наявності\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"Немає в наявності\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"Ціна\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"Немає інформації\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"Перевірка...\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"В черзі\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"Історія\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"Прев'ю\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"З помилками\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"Позначити все як переглянуте\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"Позначити все як переглянуте в '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"Непрочитане\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"Перевірити всі\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"в '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"Ще ні\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"Вже авторизовані\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"Ви повинні бути авторизовані, будь ласка, увійдіть.\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"Невірний пароль\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"Повинен бути вказаний хоча б один часовий інтервал (тижні, дні, години, хвилини або секунди).\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"Повинен бути вказаний хоча б один часовий інтервал, якщо не використовуються глобальні налаштування.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"Невірний формат часу. Використовуйте ГГ:ХХ.\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"Неприпустиме ім'я часового поясу\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"не задано\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"Почати о\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"Тривалість виконання\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"Використовувати планувальник часу\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"Опціональний часовий пояс для запуску\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"Понеділок\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"Вівторок\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"Середа\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"Четвер\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"П'ятниця\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"Субота\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"Неділя\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"Тижні\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"Має містити нуль або більше секунд\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"Дні\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"Години\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"Хвилини\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"Секунди\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"Тіло та заголовок сповіщення обов'язкові, якщо використовується URL сповіщення\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"'%s' не є допустимим URL AppRise.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"RegEx '%s' не є допустимим регулярним виразом.\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"'%s' не є допустимим виразом XPath. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"'%s' не є допустимим виразом JSONPath. (%s)\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"'%s' не є допустимим виразом jq. (%s)\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"Порожнє значення неприпустиме.\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"Неприпустиме значення.\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"Тег групи\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"Завдання\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"Процесор\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"Редагувати > Завдання\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"Метод завантаження\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"Тіло сповіщення\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"Формат сповіщення\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"Заголовок сповіщення\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"Список URL сповіщень\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"Процесор — Чого ви хочете досягти?\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"Часовий пояс за замовчуванням для планувальника перевірок\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"Чекати секунд перед вилученням тексту\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"Має містити одну або більше секунд\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL-адреси\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"Завантажити файл .xlsx\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"Має бути файл .xlsx!\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"Зіставлення файлу\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"Операція\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"Селектор\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"значення\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"Час між перевірками\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"Використовувати глобальні налаштування для часу між перевірками та планувальника.\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"Фільтри CSS/JSONPath/JQ/XPath\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"Видалити елементи\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"Вилучити текст\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"Назва\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"Ігнорувати рядки, що містять\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"Тіло запиту\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"Метод запиту\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"Ігнорувати коди статусу (обробляти коди не-2xx як звичайно)\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"Спрацьовувати лише при появі унікальних рядків у всій історії\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"Видалити дублікати рядків тексту\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"Сортувати текст за алфавітом\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"Прибирати проігноровані рядки\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"Обрізати пробіли до та після тексту\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"Додані рядки\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"Замінені/змінені рядки\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"Видалені рядки\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"Ключові слова-тригери - Спрацювати/чекати текст\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"Блокувати виявлення змін, поки текст збігається\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"Виконати JavaScript перед виявленням змін\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"Зберегти\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"Проксі\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"Надіслати сповіщення, коли фільтр більше не може бути знайдений на сторінці\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"Заглушено\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"Увімк\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"Сповіщення\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"Прикріпити скріншот до сповіщення (де можливо)\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"Збіг\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"Збіг усіх наступних умов\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"Збіг будь-якої з наступних умов\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"Використовувати <title> сторінки у списку\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"Кількість збережених записів історії для кожного завдання\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"Тіло має бути порожнім, якщо метод запиту встановлено на GET\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"Невірний синтаксис конфігурації шаблону: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"Невірний синтаксис шаблону: %(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"Невірний синтаксис шаблону в заголовку \\\"%(header)s\\\": %(error)s\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"Ім'я\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"URL проксі\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"URL проксі повинні починатися з http://, https:// або socks5://\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"URL підключення браузера\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"URL браузера повинні починатися з wss:// або ws://\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"Текстові запити\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Запити Chrome\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"Проксі за замовчуванням\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"Випадковий джитер (сек) ± перевірка\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"Кількість воркерів завантаження\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"Має бути між 1 та 50\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"Тайм-аут запитів у секундах\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"Має бути між 1 та 999\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"Перевизначення User-Agent за замовчуванням\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"Потрібно вказати і ім'я, і URL проксі.\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"Відкривати сторінку 'Історія' у новій вкладці\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"Оновлення UI в реальному часі увімкнено\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"Фавіконки увімкнено\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"Використовувати <title> сторінки у списку огляду завдань\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"Перевірку безпеки токена доступу API увімкнено\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"Перевизначення базового URL сповіщень\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"Вважати порожні сторінки зміною?\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"Ігнорувати текст\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"Ігнорувати пробіли\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"Має бути між 0 та 100\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"Пароль\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"Розмір сторінки (пагінація)\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"Має бути як мінімум нуль (вимкнено)\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"Формат контенту RSS\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"Тіло <description> RSS будується з\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"Перевизначення шаблону RSS \\\"Системний за замовчуванням\\\"\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"Видалити пароль\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"Рендерити контент тегу anchor\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"Дозволити анонімний доступ до історії завдань при увімкненому паролі\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"Приховати заглушені завдання з RSS-стрічки\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"Увімкнути режим RSS-рідера \"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"Кількість змін для показу в RSS-стрічці завдання\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"Має містити нуль або більше спроб\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"Кількість разів, яку фільтр може бути відсутнім перед надсиланням сповіщення\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"RegEx для вилучення\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"Вилучити як CSV\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"Збігів не знайдено під час сканування всієї історії цього завдання за даним RegEx.\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"Недостатньо історії для порівняння. Потрібно мінімум 2 знімки.\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"Не вдалося завантажити скриншоти: {}\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"Не вдалося розрахувати різницю: {}\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"Значення обмежувальної рамки занадто довге\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"Обмежувальна рамка має бути у форматі: x,y,ширина,висота (тільки цілі числа)\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"Значення обмежувальної рамки мають бути невід'ємними\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"Значення обмежувальної рамки занадто великі\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"Режим вибору має бути \\\"елемент\\\" або \\\"малювання\\\"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"Мінімальний відсоток змін\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"Чутливість до різниці пікселів\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"Використовувати глобальне значення за замовчуванням\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"Обмежувальна рамка\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"Режим вибору\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"Значення режиму вибору занадто довге\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"Порівняння скриншотів\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"Попередній перегляд недоступний — знімків поки немає\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"Візуальне / Скріншотне виявлення змін\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"Порівнює скриншоти, використовуючи швидкий алгоритм OpenCV, в 10-100 разів швидше за SSIM\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"Виявлення поповнення (restock)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"Тільки в наявності (Немає в наявності -> Тільки в наявності)\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"Будь-які зміни доступності\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"Вимк, не відстежувати наявність/поповнення\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"Ціна нижче для спрацювання сповіщення\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"Без обмежень\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"Ціна вище для спрацювання сповіщення\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"Поріг у %% для зміни ціни від початкової\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"Має бути між 0 та 100\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"Стежити за зміною ціни\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"Виявлення поповнення та ціни\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"Виявлення поповнення та ціни для сторінок з ОДНИМ товаром\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"Визначає, чи повернувся товар у наявність\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"Зміни тексту веб-сторінки/HTML, JSON та PDF\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"Виявляє всі текстові зміни, де це можливо\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"Помилка отримання метаданих для {}\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"Протокол завдання не дозволено або невірний формат URL\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"Досягнуто ліміту завдань ({}/{}). Неможливо додати більше.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"Тіло для всіх сповіщень — Ви можете використовувати\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"шаблонізацію в заголовку, тілі та URL сповіщення, а також токени нижче.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"Показати токени/заповнювачі\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"Токен\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"Опис\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"URL екземпляра changedetection.io, який ви використовуєте.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"URL, за яким ведеться спостереження.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"UUID завдання.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"Заголовок сторінки завдання, використовує <title>, якщо не задано - URL\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"Група / тег завдання\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"URL сторінки попереднього перегляду, створеної changedetection.io.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"URL виводу різниці (diff) для завдання.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"Вивід diff - тільки зміни, додавання та видалення\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"Вивід diff - тільки зміни, додавання та видалення —\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"Без префікса (додано) або кольорів\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"Вивід diff - тільки зміни та додавання\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"Вивід diff - тільки зміни та додавання —\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"Вивід diff - тільки зміни та видалення\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"Вивід diff - тільки зміни та видалення —\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"Вивід diff - повний вивід різниці\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"Вивід diff - повний вивід різниці —\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"Вивід diff - патч у форматі unified\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"Текстовий вміст поточного знімка, корисно при використанні JSON або CSS фільтрів\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"Текст, що викликав спрацювання фільтрів\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"Увага: Вміст\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"та\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"залежить від того, як алгоритм різниці сприймає зміну.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"Наприклад, додавання або видалення в деяких випадках може бути сприйняте як зміна.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"Детальніше тут\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"URL сповіщень AppRise\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"для сповіщень практично в будь-який сервіс!\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"Будь ласка, прочитайте вікі по сервісах сповіщень тут для важливих нотаток щодо конфігурації\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"Використовуйте\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"Показати розширену довідку та поради\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"(або\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"підтримує максимум\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"2000 символів\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"тексту сповіщення, включаючи заголовок.\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"боти не можуть надсилати повідомлення іншим ботам, тому ви повинні вказати ID чату користувача (не бота).\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"підтримує дуже обмежений HTML і може видати помилку при надсиланні зайвих тегів,\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"(або використовуйте формат plaintext/markdown)\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"для прямих викликів API (або опустіть\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"для не-SSL, тобто\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"більше довідки тут\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"Приймає\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"заповнювачі, перелічені нижче\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"Надіслати тестове сповіщення\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"Додати email\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"Додати адресу електронної пошти\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"Журнали налагодження сповіщень\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"Обробка...\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"Заголовок для всіх сповіщень\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"Для JSON навантажень використовуйте\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"без лапок для автоматичного екранування, наприклад -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"URL кодування, використовуйте\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"наприклад -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"Заміна за регулярним виразом, використовуйте\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"Для повного довідника по всіх вбудованих фільтрах Jinja2 зверніться до\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"Формат для всіх сповіщень\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"Запис\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"Дії\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"Додати рядок/правило після\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"Видалити цей рядок/правило\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"Перевірити це правило на поточному знімку\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"Помилка - Це завдання потребує Chrome (playwright/sockpuppetbrowser), але завантаження через Chrome не увімкнено.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"Альтернативно спробуйте наш\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"дуже доступний сервіс за передплатою, де все це вже налаштовано для вас\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"Вам може знадобитися\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"Увімкнути змінну оточення playwright\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"та розкоментувати\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"у\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"файлі\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"Встановити розклад по годинах/днях тижня\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"Обмеження часу розкладу\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"Робочі години\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"Вихідні\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"Скидання\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"Увага, один або кілька ваших 'днів' мають тривалість, що переходить на наступний день.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"Це може призвести до непередбачених наслідків.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"Більше довідки та прикладів використання планувальника\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"Хочете використовувати розклад за часом?\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"Спочатку підтвердіть/збережіть налаштування часового поясу\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"Викликає зміну, якщо цей текст з'являється І щось змінилося в документі.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"Тригерний текст\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"Ігнорується при розрахунку змін, але все ще відображається.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"Ігнорований текст\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"Виявлення змін не відбудеться, оскільки цей текст існує.\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"Блокуючий текст\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"Пошук, або використовуйте Alt+S\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"Оновлення в реальному часі вимкнено\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"Обрати мову\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"Автовизначення з браузера\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"Підтримка мов у бета-версії, будь ласка, допоможіть нам покращити її, відкривши PR на GitHub з оновленнями.\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"Пошук\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"URL або Назва\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"у\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"Введіть пошуковий запит...\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"Текст для очікування перед спрацьовуванням зміни/сповіщення, весь текст і regex перевіряються без урахування регістру.\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"Тригерний текст обробляється з результуючого тексту, отриманого після застосування CSS/JSON фільтрів для цього завдання\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"Кожен рядок обробляється окремо (сприймайте кожен рядок як \\\"АБО\\\")\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"Примітка: Обгорніть у скісну риску / для використання regex, приклад:\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"Текст, що збігається, буде проігноровано у текстовому знімку (ви його побачите, але він не викличе сповіщення)\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\"Блокувати виявлення змін, поки цей текст є на сторінці. Весь текст і regex без урахування регістру. Корисно \"\n\"для очікування, коли товар знову з'явиться в наявності\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"Блокуючий текст обробляється з результуючого тексту після CSS/JSON фільтрів для цього завдання\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"Усі рядки тут не повинні існувати (кожен рядок як \\\"АБО\\\")\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"Вилучає текст у фінальний вивід (по-рядково) після інших фільтрів, використовуючи регулярні вирази або збіг рядків:\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"Регулярний вираз - приклад\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"Не забудьте врахувати пробіли на початку рядка\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"прапори типу (більше\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"інформації тут\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"Ключове слово - приклад\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"Використовуйте групи для вилучення тільки цього тексту - приклад\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"повертає тільки список років\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"Приклад - рядки, що містять ключове слово\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"Один рядок на регулярний вираз/збіг\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"Вхід\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"ГРУПИ\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"НАЛАШТУВАННЯ\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"ІМПОРТ\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"Відновити авто-планування\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"Призупинити авто-чергу завдань\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"Планування на паузі - натисніть для відновлення\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"Увімкнути сповіщення\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"Вимкнути сповіщення\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"Сповіщення вимкнено - натисніть для увімкнення\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"РЕДАГУВАТИ\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"ВИЙТИ\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"Виявлення змін веб-сайтів та сповіщення.\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"Переключити Світлий/Темний режим\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"Переключити світлий/темний режим\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"Змінити мову\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"Змінити мову\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"Так\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"Ні\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"Головні налаштування\""
  },
  {
    "path": "changedetectionio/translations/zh/LC_MESSAGES/messages.po",
    "content": "# Chinese translations for PROJECT.\n# Copyright (C) 2026 ORGANIZATION\n# This file is distributed under the same license as the PROJECT project.\n# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PROJECT VERSION\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-18 21:31+0800\\n\"\n\"Last-Translator: 吾爱分享 <admin@wuaishare.cn>\\n\"\n\"Language: zh\\n\"\n\"Language-Team: zh <LL@li.org>\\n\"\n\"Plural-Forms: nplurals=1; plural=0;\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"备份正在进行中，请几分钟后再查看\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"备份数量已达上限，请先删除部分备份\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"备份正在后台生成，请几分钟后再查看。\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"备份已删除。\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"备份正在运行！\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"在此可下载并请求新的备份，备份完成后会在下方列表中显示。\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"MB\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"未找到备份。\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"创建备份\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"删除备份\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"仅导入列表前 5,000 个 URL，其余可稍后继续导入。\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"从列表导入 {} 条，用时 {:.2f} 秒，跳过 {} 条。\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"无法读取 JSON 文件，文件是否损坏？\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"JSON 结构无效，文件是否损坏？\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"从 Distill.io 导入 {} 条，用时 {:.2f} 秒，跳过 {} 条。\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"无法读取导出的 XLSX 文件，文件是否有问题？\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"处理第 {} 行时出错，URL 值不正确，已跳过该行。\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"处理第 {} 行时出错，请检查单元格数据类型是否正确，已跳过该行。\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"从 Wachete .xlsx 导入 {} 条，用时 {:.2f} 秒\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"从自定义 .xlsx 导入 {} 条，用时 {:.2f} 秒\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"URL 列表\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX 与 Wachete\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"每行输入一个 URL，可在 URL 后用空格追加标签，标签以逗号 (,) 分隔：\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"示例：\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"未通过验证的 URL 会保留在文本框中。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"复制并粘贴 Distill.io 监控的“导出”文件（JSON）。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"这项功能为\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"实验性\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"，支持的字段有\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"其余字段（包括\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"）将被忽略。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"如何导出？\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"如有需要，请将默认抓取器设置为 Chrome。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"用于以下内容的自定义列与数据类型映射表\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"自定义映射\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"文件映射类型。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"列 #\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"类型\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"无\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"CSS/XPath 过滤器\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"分组/标签名称\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"复检间隔（分钟）\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"导入\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"已移除密码保护。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"警告：工作线程数（{}）接近或超过可用CPU核心数（{}）\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"工作线程数已调整：{}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"同步工作线程不支持动态调整\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"调整工作线程时出错：{}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"已启用密码保护。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"设置已更新。\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"发生错误，请查看下方详情。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"API Key 已重新生成。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"自动调度已暂停 - 检查任务不会入队。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"自动调度已恢复 - 检查任务将正常入队。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"所有通知已静音。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"所有通知已取消静音。\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"通知调试日志\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"通用\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"抓取\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"全局过滤器\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"界面选项\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"备份\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"时间与日期\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"验证码与代理\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"信息\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"所有监控项的默认复检间隔，当前系统最小值为\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"更多信息\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"CSS/xPath 过滤器连续缺失此次数后发送通知\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"设置为\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"以禁用\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"为你的 changedetection.io 应用启用密码保护。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"密码已锁定。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"启用密码时允许访问监视器更改历史页面(便于共享差异页面)\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"当请求无内容返回，或 HTML 不包含任何文本时，是否视为变更？\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"为所有监视器选择默认代理\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"用于通知链接中的\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"通知链接中的令牌。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"默认值为系统环境变量\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"在此阅读更多\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"方法(默认)，适用于无需 JavaScript 渲染的监视网站。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"使用\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"基础\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"方法需要连接到运行中的 WebDriver+Chrome 服务器，通过环境变量设置\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"该\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"Chrome/JavaScript\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"如果页面渲染未完成（缺文本等），可尝试增加这里的等待时间。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"这将等待\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"秒后再提取文本。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"用于处理监控项的并发工作线程数。线程越多=处理更快但内存占用更高。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"当前运行:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"运行中\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"工作进程\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"活跃处理中\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"示例 - 3 秒随机抖动可能导致提前最多 3 秒或延后最多 3 秒触发\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"对于普通明文请求（非 Chrome），超时时间上限为 1-999 秒。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"适用于所有请求。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"注意：仅更换 User-Agent 往往无法绕过反爬虫技术，务必考虑\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"浏览器被识别的各种方式\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"提示：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"使用 Bright Data 和 Oxylabs 代理连接，更多信息见此处。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"判断是否变更时忽略空格、制表符和换行。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"注意:\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"更改此项会改变现有监控项状态，可能触发警报等。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"渲染 a 标签内容，默认关闭，开启后链接会呈现为\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"更改此项可能影响现有监控项内容，可能触发警报等。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"在文本转换前通过 CSS 和 XPath 选择器移除 HTML 元素。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"不要在此粘贴 HTML，仅使用 CSS 和 XPath 选择器\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"每行添加多个元素、CSS 或 XPath 选择器，用于忽略 HTML 的多个部分。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"注意：除每个监控项规则外，此项还会全局应用。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"匹配的文本将会\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"已忽略\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"在文本快照中（仍可见但不会触发变更）\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"每行单独处理，匹配的行会被忽略（在生成校验和前移除）\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"支持正则表达式，整行用斜杠 / 包裹\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"更改此项会影响对比校验和，可能触发警报\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"从输出中移除“忽略文本”中的内容（否则仅在变更检测时忽略）\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"API 访问\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"通过 API 控制 changedetection.io，更多信息\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"API访问和示例\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"通过以下方式限制 API 访问\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"请求头 - Chrome 扩展正常工作所需\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"API密钥\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"复制\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"重新生成 API Key\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Chrome 扩展程序\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"可在 Chrome 中轻松将任意网页添加到你的 changedetection.io。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"步骤 1\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"安装扩展，\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"步骤 2\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"访问此页面，\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"步骤 3\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"从工具栏打开扩展并点击\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"同步 API 访问\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"试试我们新的 Chrome 扩展！\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"Chrome 商店图标\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Chrome网上应用店\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"监控项 RSS 中包含的历史快照最大数量。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"用于监控其他 RSS 源 - 监控 RSS/Atom 源时，将其转换为纯净文本以更好地检测变更。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"你的阅读器支持 HTML 吗？在此设置\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"“系统默认”用于所有条目使用相同模板，或复用“通知正文”作为模板。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"请确认以下设置正确，它们用于管理网页监控的时间调度。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"服务器 UTC 时间与日期：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"浏览器本地时间与日期：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"启用此设置可在新标签页打开差异页面；若禁用，将在当前标签页打开。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"启用实时界面更新（更改后需重启）\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"在监控列表中启用或禁用站点图标\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"监控概览列表每页数量，0 为禁用。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"提示\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"对于被封锁的网站，“住宅”和“移动”代理类型可能比“数据中心”更有效。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"“名称”将用于在监控项编辑设置中选择代理\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"带认证的 SOCKS5 代理仅支持“明文请求”抓取器，其他抓取器请改为白名单 IP\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"Python 版本：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"已启用插件：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"未启用任何插件\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"返回\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"清除快照历史\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"标签“{}”已存在\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"标签已添加\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"标签已删除，正在后台从监视器中移除\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"正在后台从监控项中解绑标签\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"已删除所有标签，正在后台从监控项中清理\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"未找到标签\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"已更新\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"过滤器与触发器\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"这些设置会\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"应用\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"到现有的所有监控项配置中。\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"文本过滤\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"谨慎使用！\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"这很容易占满邮件存储配额或淹没其他存储。\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"注意！\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"注意！\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"当前有\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"系统级通知 URL 已启用\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"此表单将仅覆盖该监控项的通知设置\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"即使此处通知 URL 列表为空，仍会发送通知。\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"使用系统默认值\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"添加新的组织标签\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"分组 / 标签\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"分组可让您在一个组织标签下管理多个监控项的过滤器与通知。\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"监控项数量\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"标签/名称\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"未配置分组/标签\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"编辑\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"重新检查\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"删除分组？\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>确定要删除分组 <strong>%(title)s</strong> 吗？</p><p>此操作不可撤销。</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"删除\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"删除并移除标签\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"取消分组关联？\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"<p>确定要将分组 <strong>%(title)s</strong> 与所有监控项解绑吗？</p><p>标签会保留，但监控项将从该分组移除。</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"解绑\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"保留标签但解绑所有监控项\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"该监控项的 RSS 源\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"已删除 {} 个监控项\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"已暂停 {} 个监控项\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"已取消暂停 {} 个监控项\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"已更新 {} 个监控项\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"已静音 {} 个监控项\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"已取消静音 {} 个监控项\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"已将 {} 个监控项加入重新检查队列\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"已清除 {} 个监控项的错误\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"已清除/重置 {} 个监控项的历史记录。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"已将 {} 个监控项设置为使用默认通知\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"已为 {} 个监控项打标签\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"未找到监控项\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"已清除监控项 {} 的快照历史\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"历史清理已在后台开始\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"确认文本不正确。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"UUID 为 {} 的监控项不存在。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"已删除。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"已克隆，正在编辑新的监控项。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"监视器已在队列中或正在检查。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"已将 1 个监控项加入重新检查队列。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"{}个监视器已加入队列({}个已在队列中)。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"已将 {} 个监控项加入重新检查队列。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"后台将监视器加入重新检查队列...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"无法分享，与分享服务器通信时出错 - {}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"已设置为从浏览器自动检测语言\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"未找到该链接的历史记录，链接是否有误？\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"历史记录不足（需要 2 个快照），无法显示该监控项的差异页。\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"没有可编辑的监控项\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"未找到 UUID 为 {} 的监控项。\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"已切换到模式 - {}。\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"监控项已更新并取消暂停！\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"监控项已更新。\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"无法预览 - 尚未完成抓取/检查或未满足触发条件\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"这将删除所有监控项的版本历史（快照），但会保留 URL 列表！\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"你可能需要先使用\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"备份\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"链接。\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"确认文本\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"请输入单词\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"clear\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"以确认你已理解。\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"清除历史记录！\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"取消\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"将差异分享为图片\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"分享为图片\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"忽略匹配以下内容的行\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"忽略匹配以下内容的行（排除数字）\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"从\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"到\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"单词\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"行\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"忽略空白\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"未变化\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"已删除\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"新增\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"替换\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"快捷键：\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"上一项\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"下一项\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"跳转到下一个差异\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"跳转\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"错误文本\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"错误截图\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"文本\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"当前截图\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"提取数据\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"秒前。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"秒前\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"最近请求的错误截图\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"提示：你可以启用\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"“启用密码时允许共享访问”\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"（设置中）\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"查看单个快照\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"选中文本以分享或加入忽略列表。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"目前差异仅按文本比较，非图形对比，只提供最新截图。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"最近请求的当前截图\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"目前还没有截图！请尝试重新检查页面。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"截图需要启用 Playwright/WebDriver\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"请求\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"浏览器步骤\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"可视化过滤器选择器\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"条件\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"统计\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"有些网站使用 JavaScript 生成内容，此时你应该\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"使用 Chrome/WebDriver 抓取器\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"URL 支持变量\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"帮助与示例在此\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"分组/标签名称\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"若检测到页面标题将自动使用，你也可以在此自定义标题/描述\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"每次检查之间的时间间隔。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"当页面上找不到该过滤器时发送通知，便于知晓页面已变化且过滤器不再适用。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"方式（默认），适用于无需 JavaScript 渲染的网站。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"方式需要连接正在运行的 WebDriver+Chrome 服务器，通过环境变量 'WEBDRIVER_URL' 设置。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"检查/扫描全部\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"为此监控项选择代理\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"使用当前全局默认设置\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"显示高级选项\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"在执行变更检测前运行此代码，便于填写表单等操作\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"更多帮助与示例请见此处\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"请求正文支持变量\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"请求头的值支持变量\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"警告！发现额外的请求头文件，将添加到此监控项！\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"请求头也可从数据目录中的文件读取\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"在此了解更多\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"Selenium 浏览器不支持\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"开启文本查找器\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"请稍候，首个浏览器步骤可能需要一些时间加载..\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"点击此处开始\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"请等待 10-15 秒让浏览器连接。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"点击“播放”开始。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"可视化选择器数据尚未就绪，监控项至少需要检查一次。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"抱歉，此功能仅适用于支持交互式 JavaScript 的抓取器（目前仅 Playwright）。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"为支持交互式 JavaScript 的方式。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"你需要\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"设置抓取方式\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"使用验证（✓）按钮测试条件是否符合当前快照。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"阅读快速教程\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"在此了解如何使用条件式网页变更\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"启用预览\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"小贴士：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"在预览页查看高亮的过滤器和触发器。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"将触发/忽略/阻止/提取限定为;\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"注意：根据每行文本长度与相似度，算法可能把\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"视为\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"替换\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"例如。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"新增\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"因此当你关注新增内容时，最好选择\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"当内容仅在列表中移动时，也会触发\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"建议启用\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"仅当出现新的唯一行时触发\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"适合仅移动内容的网站，想知道新增内容时使用，会将新行与该监控项的全部历史进行比对。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"有助于减少因行顺序变化导致的变更，可结合\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"检查唯一行\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"一起使用。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"移除每行文本前后的空白\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"加载中...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"可视化选择器工具可让你选择\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"文本\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"用于变更检测的元素，并会自动填入“CSS/JSONPath/JQ/XPath 过滤器”选项卡中的过滤器\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"选项卡中。使用\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"Shift+点击\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"以选择多个项。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"选择模式：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"按元素选择\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"框选区域\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"清除选择\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"稍等，正在获取截图和元素信息..\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"当前：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"抱歉，此功能仅适用于支持 JavaScript 和截图的抓取器（如 Playwright 等）。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"为支持 JavaScript 和截图的方式。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"检查次数\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"连续过滤失败次数\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"历史长度\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"上次抓取耗时\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"通知告警次数\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"服务器类型响应\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"下载最新的 HTML 快照\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"删除监控项？\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"确定要删除以下监控项吗：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"此操作不可撤销。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"清除历史记录？\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"确定要清除以下监控项的全部历史记录吗：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"这将删除所有快照和历史版本。此操作不可撤销。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"清除历史记录\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"克隆并编辑\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"选择时间戳\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"前往\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"最近请求的错误截图\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"截图需要支持截图的内容抓取器（如 Sockpuppetbrowser、Selenium 等）。\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"警告：URL {} 已存在\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"监控项已以暂停状态添加，保存后将取消暂停。\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"监控项已添加。\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"显示第 <b>{start} - {end}</b> 条{record_name}，共 <b>{total}</b> 条\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"记录\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"更多信息\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"新增网页变更监控\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"监控此 URL！\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"先编辑，再监控\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"暂停\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"取消暂停\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"静音\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"取消静音\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"标签\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"标记为已读\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"使用默认通知\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"清除错误\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"清除历史记录\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>确定要清除所选项的历史记录吗？</p><p>此操作不可撤销。</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"确定\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"清除/重置历史记录\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"删除监控项？\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>确定要删除所选监控项吗？</p><p>此操作不可撤销。</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"队列大小\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"搜索中\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"全部\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"网站\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"补货与价格\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"检查\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"最近\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"变更\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"尚未配置网站监控项，请在上方输入 URL 或\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"导入列表\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"检测补货与价格\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"有库存\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"无库存\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"价格\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"暂无信息\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"正在检查\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"队列中\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"历史\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"预览\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"有错误\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"全部标记为已读\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"将“%(title)s”中的全部标记为已读\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"未读\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"重新检查全部\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"（“%(title)s”中）\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"尚未\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"秒\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"已登录\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"需要登录，请先登录。\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"密码错误\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"必须指定至少一个时间间隔（周、天、小时、分钟或秒）。\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"未使用全局设置时，必须指定至少一个时间间隔（周、天、小时、分钟或秒）。\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"时间格式无效，请使用 HH:MM。\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"不是有效的时区名称\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"未设置\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"开始时间\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"运行时长\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"使用时间调度\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"运行时使用的可选时区\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"周一\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"周二\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"周三\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"周四\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"周五\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"周六\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"周日\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"周\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"应为 0 或更多秒\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"天\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"小时\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"分钟\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"秒\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"使用通知 URL 时，必须填写通知正文和标题\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"“%s”不是有效的 AppRise URL。\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"正则表达式“%s”无效。\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"“%s”不是有效的 XPath 表达式。（%s）\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"“%s”不是有效的 JSONPath 表达式。（%s）\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"“%s”不是有效的 jq 表达式。（%s）\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"不允许为空。\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"值无效。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"分组 / 标签\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"监控项\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"处理器\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"编辑 > 监控项\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"抓取方式\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"通知正文\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"通知格式\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"通知标题\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"通知 URL 列表\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"处理器 - 您想实现什么？\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"监控检查调度器的默认时区\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"提取文本前等待秒数\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"应为 1 秒或以上\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL 列表\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"上传 .xlsx 文件\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"必须是 .xlsx 文件！\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"文件映射\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"操作\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"选择器\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"值\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"检查间隔\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"检查间隔与调度时间使用全局设置。\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"CSS/JSONPath/JQ/XPath 过滤器\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"移除元素\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"提取文本\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"标题\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"忽略包含以下内容的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"请求正文\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"请求方法\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"忽略状态代码（正常处理非 2xx 状态码）\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"仅当全历史中出现新的唯一行时触发\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"删除重复的文本行\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"按字母顺序排序文本\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"去除忽略的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"去除文本前后空白\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"新增行\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"替换/更改的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"删除的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"关键字触发器 - 触发/等待文本\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"文本匹配时阻止变更检测\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"在变更检测前执行 JavaScript\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"保存\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"代理\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"当页面上找不到该过滤器时发送通知\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"静音\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"开启\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"通知\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"（如可用）在通知中附加截图\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"匹配\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"匹配以下全部\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"匹配以下任意\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"列表中使用页面 <title>\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"当请求方法为 GET 时，请求正文必须为空\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"模板语法配置无效：%(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"模板语法无效：%(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"“%(header)s”请求头中的模板语法无效：%(error)s\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"名称\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"代理 URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"代理 URL 必须以 http://、https:// 或 socks5:// 开头\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"浏览器连接 URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"浏览器 URL 必须以 wss:// 或 ws:// 开头\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"纯文本请求\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Chrome 请求\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"默认代理\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"检查间隔随机抖动秒数 ±\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"抓取工作线程数\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"应介于 1 到 50 之间\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"请求超时（秒）\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"应介于 1 到 999 之间\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"默认 User-Agent 覆盖\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"名称和代理 URL 都是必填项。\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"在新标签页中打开“历史记录”\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"启用实时界面更新\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"启用站点图标\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"在监控概览列表中使用页面 <title>\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"已启用 API 访问令牌安全检查\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"通知基础 URL 覆盖\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"将空页面视为变更？\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"忽略文本\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"忽略空白\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"必须介于 0 到 100 之间\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"密码\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"分页大小\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"应至少为 0（表示禁用）\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"RSS 内容格式\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"RSS <description> 内容来源\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"RSS “系统默认”模板覆盖\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"移除密码\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"渲染 <a> 标签内容\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"启用密码时允许匿名访问监控历史页面\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"在 RSS 中隐藏静音的监控项\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"启用 RSS 阅读模式\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"监控项 RSS 中显示的变更数量\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"应为 0 或更多次\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"过滤器缺失达到多少次后发送通知\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"用于提取的正则表达式\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"提取为 CSV\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"扫描全部监控历史未找到匹配该正则的内容。\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"历史记录不足以比较，至少需要 2 个快照。\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"加载截图失败：{}\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"计算差异失败：{}\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"边界框值过长\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"边界框格式必须为：x,y,width,height（仅限整数）\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"边界框值必须为非负数\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"边界框值过大\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"选择模式必须为 “element” 或 “draw”\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"最小变化百分比\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"像素差异灵敏度\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"使用全局默认\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"边界框\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"选择模式\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"选择模式值过长\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"截图对比\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"无法预览 - 仍未捕获任何快照\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"视觉/截图变更检测\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"使用快速的 OpenCV 算法对比截图，比 SSIM 快 10-100 倍\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"补货检测\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"仅有库存（缺货 -> 有库存）\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"任何库存变化\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"关闭，不跟踪库存/补货\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"低于该价格时触发通知\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"不限\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"高于该价格时触发通知\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"相对原始价格的变动阈值（百分比）\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"应介于 0 到 100 之间\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"跟踪价格变化\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"补货与价格检测\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"适用于单一商品页面的补货与价格检测\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"检测商品是否恢复有库存\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"网页文本/HTML、JSON 和 PDF 变更\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"尽可能检测所有文本变更\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"获取 {} 的元数据失败\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"监控协议不允许或 URL 格式无效\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"所有通知的正文 — 您可以使用\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"可在通知标题、正文和 URL 中使用模板，并使用下方的令牌/占位符。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"显示令牌/占位符\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"令牌\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"描述\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"你正在运行的 changedetection.io 实例的 URL。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"被监控的 URL。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"监视器的UUID。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"监控项的页面标题，未设置时使用 <title>，否则回退为 URL\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"监视器组/标签\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"changedetection.io 生成的预览页面 URL。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"该监控项的差异输出 URL。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"差异输出 - 仅包含更改、新增与删除\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"差异输出 - 仅包含更改、新增与删除 —\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"不含（added）前缀或颜色\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"差异输出 - 仅包含更改与新增\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"差异输出 - 仅包含更改与新增 —\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"差异输出 - 仅包含更改与删除\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"差异输出 - 仅包含更改与删除 —\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"差异输出 - 完整差异内容\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"差异输出 - 完整差异内容 —\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"差异输出 - 统一格式补丁\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"当前快照的文本内容值，与 JSON 或 CSS 过滤器结合使用时很有用\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"由过滤器触发的文本\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"警告：以下内容\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"和\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"取决于差异算法对变更的判断。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"例如，在某些情况下，新增或删除可能被视为变更。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"更多信息\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"AppRise通知URL\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"用于向几乎任何服务发送通知！\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"请阅读通知服务 Wiki 以了解重要配置说明\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"使用\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"显示高级帮助和提示\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"（或\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"最多仅支持\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"2,000 个字符\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"通知文本,包括标题。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"机器人无法向其他机器人发送消息，因此应指定非机器人用户的聊天 ID。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"仅支持非常有限的 HTML，发送额外标签可能失败，\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"（或使用纯文本/Markdown 格式）\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"用于直接 API 调用（或省略\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"用于非 SSL，例如\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"更多帮助\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"接受以下\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"下方列出的占位符\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"发送测试通知\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"添加邮箱\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"添加邮箱地址\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"通知调试日志\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"处理中..\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"所有通知的标题\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"对于 JSON 负载，使用\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"无需引号以自动转义，例如 -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"URL 编码使用\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"例如 -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"正则替换使用\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"关于 Jinja2 内置过滤器的完整参考，请见\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"所有通知的格式\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"条目\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"操作\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"在后面添加一行/规则\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"移除此行/规则\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"使用当前快照验证此规则\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"错误 - 该监控项需要 Chrome（playwright/sockpuppetbrowser），但未启用基于 Chrome 的抓取。\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"也可以试试我们的\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"价格实惠的订阅服务，已为你完成全部配置\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"您可能需要\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"启用 Playwright 环境变量\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"并取消注释\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"在\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"文件\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"设置按小时/工作日计划\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"计划时间限制\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"工作时间\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"周末\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"重置\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"警告：一个或多个“天”的时长会延续到次日。\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"这可能导致意外结果。\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"有关使用调度器的更多帮助\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"想要使用时间计划吗?\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"请先确认/保存你的时区设置\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"当该文本出现且文档发生变化时触发变更。\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"触发文本\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"计算变更时忽略，但仍会显示。\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"忽略文本\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"此文本存在时将不会进行变更检测。\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"阻止文本\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"搜索，或使用 Alt+S 快捷键\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"实时更新离线\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"选择语言\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"从浏览器自动检测\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"语言支持仍在测试阶段，欢迎在 GitHub 提交 PR 帮助改进。\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"搜索\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"URL 或标题\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"在\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"输入搜索关键词...\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"触发变更/通知前等待的文本，所有文本和正则均不区分大小写。\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"触发文本来自该监控项的 CSS/JSON 过滤结果\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"每行单独处理（可理解为每行都是“或”）\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"注意：使用正则时请用斜杠 / 包裹，例如：\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"匹配的文本会在文本快照中被忽略（仍可见但不会触发变更）\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"当页面出现这些文本时阻止变更检测，所有文本和正则均不区分大小写，适合等待商品重新上架\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"阻止文本来自该监控项的 CSS/JSON 过滤结果\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"此处所有行必须不存在（每行视为“或”）\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"在其他过滤器之后，按行从最终输出中提取文本（使用正则或字符串匹配）：\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"正则表达式 - 示例\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"别忘了考虑行首空白\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"类型标志（更多\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"此处信息\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"关键字示例 - 示例\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"使用分组仅提取该文本 - 示例\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"仅返回年份列表\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"示例 - 匹配包含关键字的行\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"每行一个正则/字符串匹配规则\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"登录\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"分组\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"设置\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"导入\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"恢复自动调度\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"暂停监控项自动入队\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"调度已暂停 - 点击恢复\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"取消静音通知\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"静音通知\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"通知已静音 - 点击取消静音\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"编辑\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"退出登录\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"网站变更检测与通知。\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"切换亮/暗模式\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"切换亮/暗模式\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"切换语言\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"切换语言\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"是\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"否\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"主设置\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"无法加载处理器/插件 '{}' 的编辑表单，插件是否缺失？\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"创建可分享链接\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"提示：你也可以添加“共享”的监控项。\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"正在后台将监控项标记为已读...\"\n\n"
  },
  {
    "path": "changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po",
    "content": "# Chinese (Traditional, Taiwan) translations for PROJECT.\n# Copyright (C) 2026 ORGANIZATION\n# This file is distributed under the same license as the PROJECT project.\n# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PROJECT VERSION\\n\"\n\"Report-Msgid-Bugs-To: EMAIL@ADDRESS\\n\"\n\"POT-Creation-Date: 2026-02-23 03:54+0100\\n\"\n\"PO-Revision-Date: 2026-01-15 12:00+0800\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language: zh_Hant_TW\\n\"\n\"Language-Team: zh_Hant_TW <LL@li.org>\\n\"\n\"Plural-Forms: nplurals=1; plural=0;\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.16.0\\n\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"A backup is already running, check back in a few minutes\"\nmsgstr \"備份正在進行中，請稍後再回來查看\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Maximum number of backups reached, please remove some\"\nmsgstr \"已達備份數量上限，請移除部分備份\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backup building in background, check back in a few minutes.\"\nmsgstr \"正在背景建立備份，請稍後再回來查看。\"\n\n#: changedetectionio/blueprint/backups/__init__.py\nmsgid \"Backups were deleted.\"\nmsgstr \"備份已刪除。\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Backup zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Must be a .zip backup file!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include groups\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing groups of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Include watches\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Replace existing watches of the same UUID\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore backup\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"A restore is already running, check back in a few minutes\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"No file uploaded\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"File must be a .zip backup file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Invalid or corrupted zip file\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/restore.py\nmsgid \"Restore started in background, check back in a few minutes.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Create\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"A backup is running!\"\nmsgstr \"備份正在執行中！\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Here you can download and request a new backup, when a backup is completed you will see it listed below.\"\nmsgstr \"您可以在此下載並請求建立新備份，備份完成後將顯示於下方。\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Mb\"\nmsgstr \"MB\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"No backups found.\"\nmsgstr \"找不到備份。\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Create backup\"\nmsgstr \"建立備份\"\n\n#: changedetectionio/blueprint/backups/templates/backup_create.html\nmsgid \"Remove backups\"\nmsgstr \"移除備份\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"A restore is running!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Note: This does not override the main application settings, only watches and groups.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all groups found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing groups of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Include all watches found in backup?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/backups/templates/backup_restore.html\nmsgid \"Replace any existing watches of the same UUID?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Importing 5,000 of the first URLs from your list, the rest can be imported again.\"\nmsgstr \"正在匯入清單中的前 5,000 個 URL，其餘的可以再次匯入。\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from list in {:.2f}s, {} Skipped.\"\nmsgstr \"{} 已從清單匯入，耗時 {:.2f} 秒，跳過 {} 筆。\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read JSON file, was it broken?\"\nmsgstr \"無法讀取 JSON 檔案，檔案是否已損毀？\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"JSON structure looks invalid, was it broken?\"\nmsgstr \"JSON 結構看起來無效，檔案是否已損毀？\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} Imported from Distill.io in {:.2f}s, {} Skipped.\"\nmsgstr \"{} 已從 Distill.io 匯入，耗時 {:.2f} 秒，跳過 {} 筆。\"\n\n#: changedetectionio/blueprint/imports/importer.py\nmsgid \"Unable to read export XLSX file, something wrong with the file?\"\nmsgstr \"無法讀取匯出的 XLSX 檔案，檔案是否有問題？\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, URL value was incorrect, row was skipped.\"\nmsgstr \"處理第 {} 行時發生錯誤，URL 數值不正確，已跳過該行。\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"Error processing row number {}, check all cell data types are correct, row was skipped.\"\nmsgstr \"處理第 {} 行時發生錯誤，請檢查所有儲存格資料類型是否正確，已跳過該行。\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from Wachete .xlsx in {:.2f}s\"\nmsgstr \"{} 已從 Wachete .xlsx 匯入，耗時 {:.2f} 秒\"\n\n#: changedetectionio/blueprint/imports/importer.py\n#, python-brace-format\nmsgid \"{} imported from custom .xlsx in {:.2f}s\"\nmsgstr \"{} 已從自訂 .xlsx 匯入，耗時 {:.2f} 秒\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URL List\"\nmsgstr \"URL 列表\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Distill.io\"\nmsgstr \"Distill.io\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \".XLSX & Wachete\"\nmsgstr \".XLSX 和 Wachete\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Restoring changedetection.io backups is in the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"backups section\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):\"\nmsgstr \"每行輸入一個 URL，可選用空格分隔後為每個 URL 新增標籤，標籤間用逗號 (,) 分隔：\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Example:\"\nmsgstr \"範例：\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"URLs which do not pass validation will stay in the textarea.\"\nmsgstr \"未通過驗證的 URL 將保留在文字區塊中。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Copy and Paste your Distill.io watch 'export' file, this should be a JSON file.\"\nmsgstr \"複製並貼上您的 Distill.io 監測任務「匯出」檔案，這應該是一個 JSON 檔案。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"This is\"\nmsgstr \"這是\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"experimental\"\nmsgstr \"實驗性功能\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"supported fields are\"\nmsgstr \"支援的欄位有\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"the rest (including\"\nmsgstr \"其餘（包括\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"are ignored.\"\nmsgstr \"將被忽略。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"How to export?\"\nmsgstr \"如何匯出？\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Be sure to set your default fetcher to Chrome if required.\"\nmsgstr \"如果需要，請務必將您的預設抓取器設為 Chrome。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Table of custom column and data types mapping for the\"\nmsgstr \"自訂欄位與資料類型對應表，適用於\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Custom mapping\"\nmsgstr \"自訂對應\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"File mapping type.\"\nmsgstr \"檔案對應類型。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Column #\"\nmsgstr \"欄位 #\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Type\"\nmsgstr \"類型\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"none\"\nmsgstr \"無\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"CSS/xPath filter\"\nmsgstr \"CSS / xPath 過濾器\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Group / Tag name(s)\"\nmsgstr \"群組 / 標籤名稱\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Recheck time (minutes)\"\nmsgstr \"複查時間（分鐘）\"\n\n#: changedetectionio/blueprint/imports/templates/import.html\nmsgid \"Import\"\nmsgstr \"匯入\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch with UUID %(uuid)s not found\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/rss/single_watch.py\n#, python-format\nmsgid \"Watch %(uuid)s does not have enough history snapshots to show changes (need at least 2)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection removed.\"\nmsgstr \"密碼保護已移除。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Warning: Worker count ({}) is close to or exceeds available CPU cores ({})\"\nmsgstr \"警告：工作程式數量（{}）接近或超過可用CPU核心數（{}）\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Worker count adjusted: {}\"\nmsgstr \"工作程序數量已調整：{}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Dynamic worker adjustment not supported for sync workers\"\nmsgstr \"同步工作程序不支援動態調整\"\n\n#: changedetectionio/blueprint/settings/__init__.py\n#, python-brace-format\nmsgid \"Error adjusting workers: {}\"\nmsgstr \"調整工作程序時發生錯誤：{}\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Password protection enabled.\"\nmsgstr \"已啟用密碼保護。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Settings updated.\"\nmsgstr \"設定已更新。\"\n\n#: changedetectionio/blueprint/settings/__init__.py changedetectionio/blueprint/ui/edit.py\n#: changedetectionio/processors/extract.py\nmsgid \"An error occurred, please see below.\"\nmsgstr \"發生錯誤，請參見下方。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"API Key was regenerated.\"\nmsgstr \"API 金鑰已重新產生。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling paused - checks will not be queued.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"Automatic scheduling resumed - checks will be queued normally.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications muted.\"\nmsgstr \"所有通知已靜音。\"\n\n#: changedetectionio/blueprint/settings/__init__.py\nmsgid \"All notifications unmuted.\"\nmsgstr \"所有通知已取消靜音。\"\n\n#: changedetectionio/blueprint/settings/templates/notification-log.html\nmsgid \"Notification debug log\"\nmsgstr \"通知除錯記錄\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"General\"\nmsgstr \"一般\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Fetching\"\nmsgstr \"抓取\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Global Filters\"\nmsgstr \"全域過濾器\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UI Options\"\nmsgstr \"介面選項\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API\"\nmsgstr \"API\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"RSS\"\nmsgstr \"RSS\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Backups\"\nmsgstr \"備份\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Time & Date\"\nmsgstr \"時間與日期\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"CAPTCHA & Proxies\"\nmsgstr \"驗證碼與代理伺服器\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Info\"\nmsgstr \"資訊\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default recheck time for all watches, current system minimum is\"\nmsgstr \"所有監測任務的預設複查時間，目前系統最小值為\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"more info\"\nmsgstr \"更多資訊\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"After this many consecutive times that the CSS/xPath filter is missing, send a notification\"\nmsgstr \"發送通知前允許過濾器遺失的次數\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to\"\nmsgstr \"設置為\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"to disable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit collection of history snapshots for each watch to this number of history items.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Set to empty to disable / no limit\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password protection for your changedetection.io application.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Password is locked.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Allow access to the watch change history page when password is enabled (Good for sharing the diff page)\"\nmsgstr \"啟用密碼時允許匿名存取監測歷史頁面\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"When a request returns no content, or the HTML does not contain any text, is this considered a change?\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Choose a default proxy for all watches\"\nmsgstr \"為所有監測任務選擇預設代理伺服器\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Base URL used for the\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"token in notification links.\"\nmsgstr \"通知 URL 列表\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Default value is the system environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html\nmsgid \"read more here\"\nmsgstr \"在此閱讀更多內容\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method (default) where your watched sites don't need Javascript to render.\"\nmsgstr \"方法（預設），適用於您監測的網站不需要 Javascript 渲染的情況。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the\"\nmsgstr \"使用\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Basic\"\nmsgstr \"基本\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var\"\nmsgstr \"方法需要連線到執行中的 WebDriver + Chrome 伺服器，由環境變數 'WEBDRIVER_URL' 設定。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The\"\nmsgstr \"這個\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Chrome/Javascript\"\nmsgstr \"Chrome / Javascript\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time\"\n\" here.\"\nmsgstr \"如果您在等待頁面完全渲染時遇到問題（文字遺失等），請嘗試在此增加「等待」時間。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will wait\"\nmsgstr \"這會等待\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"seconds before extracting the text.\"\nmsgstr \"秒後才提取文字。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Currently running:\"\nmsgstr \"目前運行：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"operational\"\nmsgstr \"運行中\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"workers\"\nmsgstr \"工作程序\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"actively processing\"\nmsgstr \"活躍處理中\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Applied to all requests.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"all of the ways that the browser is detected\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html\nmsgid \"Tip:\"\nmsgstr \"提示：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Connect using Bright Data and Oxylabs Proxies, find out more here.\"\nmsgstr \"使用 Bright Data 和 Oxylabs 代理連接，在此處了解更多資訊。\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note:\"\nmsgstr \"注意：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this will change the status of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Render anchor tag content, default disabled, when enabled renders links as\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Changing this could affect the content of your existing watches, possibly trigger alerts etc.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove HTML element(s) by CSS and XPath selectors before text conversion.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Don't paste HTML here, use only CSS and XPath selectors\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Note: This is applied globally in addition to the per-watch rules.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Matching text will be\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"ignored\"\nmsgstr \"已忽略\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Each line processed separately, any line matching will be ignored (removed before creating the checksum)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Regular Expression support, wrap the entire line in forward slash\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html\nmsgid \"Changing this will affect the comparison checksum which may trigger an alert\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Remove any text that appears in the \\\"Ignore text\\\" from the output (otherwise its just ignored for change-detection)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Drive your changedetection.io via API, More about\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API access and examples here\"\nmsgstr \"幫助與範例請見此處\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Restrict API access limit by using\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"header - required for the Chrome Extension to work\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"API Key\"\nmsgstr \"API 金鑰\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"copy\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Regenerate API key\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Extension\"\nmsgstr \"Chrome 擴充功能\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Easily add any web-page to your changedetection.io installation from within Chrome.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 1\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Install the extension,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 2\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Navigate to this page,\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Step 3\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Open the extension from the toolbar and click\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Sync API Access\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Try our new Chrome Extension!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome store icon\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Chrome Webstore\"\nmsgstr \"Chrome 線上應用程式商店\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Maximum number of history snapshots to include in the watch specific RSS feed.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Does your reader support HTML? Set it here\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"'System default' for the same template for all items, or re-use your \\\"Notification Body\\\" as the template.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"UTC Time & Date from Server:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Local Time & Date in Browser:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Realtime UI Updates Enabled - (Restart required if this is changed)\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Enable or Disable Favicons next to the watch list\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Number of items per page in the watch overview list, 0 to disable.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Tip\"\nmsgstr \"提示\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Residential\\\" and \\\"Mobile\\\" proxy type can be more successfull than \\\"Data Center\\\" for blocked websites.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\\\"Name\\\" will be used for selecting the proxy in the Watch Edit settings\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"\"\n\"SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should \"\n\"whitelist the IP access instead\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Uptime:\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Python version:\"\nmsgstr \"Python 版本：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Plugins active:\"\nmsgstr \"啟用的外掛：\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"No plugins active\"\nmsgstr \"無啟用的外掛\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Back\"\nmsgstr \"返回\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html\nmsgid \"Clear Snapshot History\"\nmsgstr \"清除快照歷史記錄\"\n\n#: changedetectionio/blueprint/tags/__init__.py\n#, python-brace-format\nmsgid \"The tag \\\"{}\\\" already exists\"\nmsgstr \"標籤「{}」已存在\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag added\"\nmsgstr \"標籤已新增\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag deleted, removing from watches in background\"\nmsgstr \"標籤已刪除，正在背景從監測任務中移除\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Unlinking tag from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"All tags deleted, clearing from watches in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Tag not found\"\nmsgstr \"找不到標籤\"\n\n#: changedetectionio/blueprint/tags/__init__.py\nmsgid \"Updated\"\nmsgstr \"已更新\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Filters & Triggers\"\nmsgstr \"過濾器與觸發器\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"These settings are\"\nmsgstr \"這些設定會\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"added\"\nmsgstr \"新增\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html\nmsgid \"to any existing watch configurations.\"\nmsgstr \"至任何現有的監測設定中。\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Text filtering\"\nmsgstr \"文字過濾\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use with caution!\"\nmsgstr \"請謹慎使用！\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will easily fill up your email storage quota or flood other storages.\"\nmsgstr \"這很容易填滿您的電子郵件儲存配額或淹沒其他儲存空間。\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Look out!\"\nmsgstr \"注意！\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Lookout!\"\nmsgstr \"注意！\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"There are\"\nmsgstr \"目前有\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"system-wide notification URLs enabled\"\nmsgstr \"已啟用的全系統通知 URL\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"this form will override notification settings for this watch only\"\nmsgstr \"此表單將僅覆寫此監測任務的通知設定\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"an empty Notification URL list here will still send notifications.\"\nmsgstr \"此處留空的通知 URL 列表仍會發送通知。\"\n\n#: changedetectionio/blueprint/tags/templates/edit-tag.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use system defaults\"\nmsgstr \"使用系統預設值\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Add a new organisational tag\"\nmsgstr \"新增組織標籤\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch group / tag\"\nmsgstr \"群組 / 標籤\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Groups allows you to manage filters and notifications for multiple watches under a single organisational tag.\"\nmsgstr \"群組功能讓您能在單一組織標籤下，管理多個監測任務的過濾器與通知設定。\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"# Watches\"\nmsgstr \"# 監測任務\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Tag / Label name\"\nmsgstr \"標籤 / 名稱\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"No website organisational tags/groups configured\"\nmsgstr \"未設定群組/標籤\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit\"\nmsgstr \"編輯\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck\"\nmsgstr \"複查\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Delete Group?\"\nmsgstr \"刪除群組？\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>您確定要刪除群組 <strong>%(title)s</strong> 嗎？</p><p>此動作無法復原。</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete\"\nmsgstr \"刪除\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Deletes and removes tag\"\nmsgstr \"刪除並移除標籤\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink Group?\"\nmsgstr \"解除群組連結？\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\n#, python-format\nmsgid \"\"\n\"<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but \"\n\"watches will be removed from it.</p>\"\nmsgstr \"<p>您確定要將所有監測任務從群組 <strong>%(title)s</strong> 解除連結嗎？</p><p>標籤將被保留，但監測任務將從中移除。</p>\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Unlink\"\nmsgstr \"解除連結\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html\nmsgid \"Keep the tag but unlink any watches\"\nmsgstr \"保留標籤但解除任何監測任務的連結\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"RSS Feed for this watch\"\nmsgstr \"此監測任務的 RSS Feed\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches deleted\"\nmsgstr \"{} 個監測任務已刪除\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches paused\"\nmsgstr \"{} 個監測任務已暫停\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches unpaused\"\nmsgstr \"{} 個監測任務已取消暫停\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches updated\"\nmsgstr \"{} 個監測任務已更新\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches muted\"\nmsgstr \"{} 個監測任務已靜音\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches un-muted\"\nmsgstr \"{} 個監測任務已取消靜音\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches queued for rechecking\"\nmsgstr \"{} 個監測任務已排入複查佇列\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches errors cleared\"\nmsgstr \"{} 個監測任務錯誤已清除\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches cleared/reset.\"\nmsgstr \"{} 個監測任務已清除 / 重置。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches set to use default notification settings\"\nmsgstr \"{} 個監測任務已設為使用預設通知設定\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"{} watches were tagged\"\nmsgstr \"{} 個監測任務已加上標籤\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch not found\"\nmsgstr \"找不到監測任務\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Cleared snapshot history for watch {}\"\nmsgstr \"已清除監測任務 {} 的快照歷史記錄\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"History clearing started in background\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Incorrect confirmation text.\"\nmsgstr \"確認文字不正確。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"The watch by UUID {} does not exist.\"\nmsgstr \"UUID 為 {} 的監測任務不存在。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Deleted.\"\nmsgstr \"已刪除。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Cloned, you are editing the new watch.\"\nmsgstr \"已複製，您正在編輯新的監測任務。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Watch is already queued or being checked.\"\nmsgstr \"監測任務已在佇列中或正在檢查。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queued 1 watch for rechecking.\"\nmsgstr \"已將 1 個監測任務排入複查佇列。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking ({} already queued or running).\"\nmsgstr \"已將 {} 個監測任務排入複查佇列。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Queued {} watches for rechecking.\"\nmsgstr \"已將 {} 個監測任務排入複查佇列。\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Queueing watches for rechecking in background...\"\nmsgstr \"背景將監測任務排入複查佇列...\"\n\n#: changedetectionio/blueprint/ui/__init__.py\n#, python-brace-format\nmsgid \"Could not share, something went wrong while communicating with the share server - {}\"\nmsgstr \"無法分享，與分享伺服器通訊時發生錯誤 - {}\"\n\n#: changedetectionio/blueprint/ui/__init__.py\nmsgid \"Language set to auto-detect from browser\"\nmsgstr \"已設定為從瀏覽器自動偵測語言\"\n\n#: changedetectionio/blueprint/ui/diff.py changedetectionio/blueprint/ui/preview.py\nmsgid \"No history found for the specified link, bad link?\"\nmsgstr \"找不到指定連結的歷史記錄，連結無效？\"\n\n#: changedetectionio/blueprint/ui/diff.py\nmsgid \"Not enough history (2 snapshots required) to show difference page for this watch.\"\nmsgstr \"歷史記錄不足（需要 2 個快照）以顯示此監測任務的差異頁面。\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"No watches to edit\"\nmsgstr \"沒有可編輯的監測任務\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"No watch with the UUID {} found.\"\nmsgstr \"找不到 UUID 為 {} 的監測任務。\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Switched to mode - {}.\"\nmsgstr \"已切換至模式 - {}。\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing. Please select a different processor.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\n#, python-brace-format\nmsgid \"Could not load '{}' processor, processor plugin might be missing.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch - unpaused!\"\nmsgstr \"已更新監測任務 - 已取消暫停！\"\n\n#: changedetectionio/blueprint/ui/edit.py\nmsgid \"Updated watch.\"\nmsgstr \"已更新監測任務。\"\n\n#: changedetectionio/blueprint/ui/preview.py\nmsgid \"Preview unavailable - No fetch/check completed or triggers not reached\"\nmsgstr \"預覽無法使用 - 未完成抓取 / 檢查或未觸發\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"This will remove version history (snapshots) for ALL watches, but keep your list of URLs!\"\nmsgstr \"這將移除「所有」監測任務的版本歷史記錄（快照），但保留您的 URL 列表！\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"You may like to use the\"\nmsgstr \"您可能想先使用\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"BACKUP\"\nmsgstr \"備份\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"link first.\"\nmsgstr \"連結。\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Confirmation text\"\nmsgstr \"確認文字\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Type in the word\"\nmsgstr \"輸入單字\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"clear\"\nmsgstr \"clear\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"to confirm that you understand.\"\nmsgstr \"以確認您已了解。\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html\nmsgid \"Clear History!\"\nmsgstr \"清除歷史記錄！\"\n\n#: changedetectionio/blueprint/ui/templates/clear_all_history.html changedetectionio/templates/base.html\nmsgid \"Cancel\"\nmsgstr \"取消\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share diff as image\"\nmsgstr \"將差異分享為圖片\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Share as Image\"\nmsgstr \"分享為圖片\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching\"\nmsgstr \"忽略任何符合的行\"\n\n#: changedetectionio/blueprint/ui/templates/diff-offscreen-options.html\nmsgid \"Ignore any lines matching excluding digits\"\nmsgstr \"忽略任何符合（排除數字）的行\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"From\"\nmsgstr \"從\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"To\"\nmsgstr \"到\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Words\"\nmsgstr \"字\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Lines\"\nmsgstr \"行\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Ignore Whitespace\"\nmsgstr \"忽略空白\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Same/non-changed\"\nmsgstr \"相同 / 未變更\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Removed\"\nmsgstr \"已移除\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Added\"\nmsgstr \"已新增\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Replaced\"\nmsgstr \"已替換\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Keyboard:\"\nmsgstr \"鍵盤：\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Previous\"\nmsgstr \"上一個\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Next\"\nmsgstr \"下一個\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump to next difference\"\nmsgstr \"跳至下一個差異\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Jump\"\nmsgstr \"跳轉\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Text\"\nmsgstr \"錯誤文字\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Error Screenshot\"\nmsgstr \"錯誤截圖\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Text\"\nmsgstr \"文字\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot\"\nmsgstr \"目前截圖\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Extract Data\"\nmsgstr \"提取資料\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"seconds ago.\"\nmsgstr \"秒前。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"seconds ago\"\nmsgstr \"秒前\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Current error-ing screenshot from most recent request\"\nmsgstr \"最近請求的目前錯誤截圖\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Pro-tip: You can enable\"\nmsgstr \"專業提示：您可以啟用\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"\\\"share access when password is enabled\\\"\"\nmsgstr \"「啟用密碼時分享存取權限」\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"from settings.\"\nmsgstr \"於設定中。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Goto single snapshot\"\nmsgstr \"前往單一快照\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Highlight text to share or add to ignore lists.\"\nmsgstr \"反白文字以分享或新增至忽略列表。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"For now, Differences are performed on text, not graphically, only the latest screenshot is available.\"\nmsgstr \"目前，差異比對是針對文字執行，而非圖形，僅提供最新的截圖。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current screenshot from most recent request\"\nmsgstr \"最近請求的目前截圖\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"No screenshot available just yet! Try rechecking the page.\"\nmsgstr \"目前還沒有可用的截圖！請嘗試複查頁面。\"\n\n#: changedetectionio/blueprint/ui/templates/diff.html\nmsgid \"Screenshot requires Playwright/WebDriver enabled\"\nmsgstr \"截圖需要啟用 Playwright / WebDriver\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Request\"\nmsgstr \"請求\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Browser Steps\"\nmsgstr \"瀏覽器步驟\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Filter Selector\"\nmsgstr \"視覺過濾選擇器\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Conditions\"\nmsgstr \"條件\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Stats\"\nmsgstr \"統計數據\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Some sites use JavaScript to create the content, for this you should\"\nmsgstr \"有些網站使用 JavaScript 來建立內容，為此您應該\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"use the Chrome/WebDriver Fetcher\"\nmsgstr \"使用 Chrome / WebDriver 抓取器\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the URL\"\nmsgstr \"URL 中支援變數\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"help and examples here\"\nmsgstr \"幫助與範例請見此處\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Organisational tag/group name used in the main listing page\"\nmsgstr \"群組/標籤名稱\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Automatically uses the page title if found, you can also use your own title/description here\"\nmsgstr \"如果找到頁面標題將自動使用，您也可以在此使用您自己的標題 / 描述\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The interval/amount of time between each check.\"\nmsgstr \"每次檢查之間的間隔 / 時間量。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and \"\n\"your filter will not work anymore.\"\nmsgstr \"當頁面上找不到過濾器時發送通知，這有助於了解頁面何時變更導致您的過濾器失效。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set to empty to use system settings default\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method (default) where your watched site doesn't need Javascript to render.\"\nmsgstr \"方法（預設），適用於您監測的網站不需要 Javascript 渲染的情況。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'.\"\nmsgstr \"方法需要連線到執行中的 WebDriver + Chrome 伺服器，由環境變數 'WEBDRIVER_URL' 設定。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check/Scan all\"\nmsgstr \"檢查 / 掃描全部\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Choose a proxy for this watch\"\nmsgstr \"為此監測任務選擇代理伺服器\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Using the current global default settings\"\nmsgstr \"使用目前全域預設設定\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Show advanced options\"\nmsgstr \"顯示進階選項\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Run this code before performing change detection, handy for filling in fields and other actions\"\nmsgstr \"在執行變更檢測之前執行此程式碼，方便填寫欄位和其他動作\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"More help and examples here\"\nmsgstr \"更多幫助和範例請見此處\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request body\"\nmsgstr \"請求內容中支援變數\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Variables are supported in the request header values\"\nmsgstr \"請求標頭值支援變數\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Alert! Extra headers file found and will be added to this watch!\"\nmsgstr \"警報！找到額外的標頭檔案，將新增至此監測任務！\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Headers can be also read from a file in your data-directory\"\nmsgstr \"標頭也可以從您的資料目錄中的檔案讀取\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read more here\"\nmsgstr \"在此閱讀更多內容\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Not supported by Selenium browser\"\nmsgstr \"Selenium 瀏覽器不支援\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Turn on text finder\"\nmsgstr \"開啟文字尋找器\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please wait, first browser step can take a little time to load..\"\nmsgstr \"請稍候，第一個瀏覽器步驟可能需要一點時間載入..\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Click here to Start\"\nmsgstr \"點擊此處開始\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Please allow 10-15 seconds for the browser to connect.\"\nmsgstr \"請等待 10-15 秒讓瀏覽器連線。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Press \\\"Play\\\" to start.\"\nmsgstr \"按 \\\"Play\\\" 開始。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Visual Selector data is not ready, watch needs to be checked atleast once.\"\nmsgstr \"視覺選擇器資料尚未準備好，監測任務至少需要檢查一次。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based \"\n\"fetchers)\"\nmsgstr \"抱歉，此功能僅適用於支援互動式 Javascript 的抓取器（目前僅限基於 Playwright 的抓取器）\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports interactive Javascript.\"\nmsgstr \"為支援互動式 Javascript 的方式。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"You need to\"\nmsgstr \"您需要\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Set the fetch method\"\nmsgstr \"設定抓取方式\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the verify (✓) button to test if a condition passes against the current snapshot.\"\nmsgstr \"使用驗證 (✓) 按鈕測試條件是否符合目前的快照。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Read a quick tutorial about\"\nmsgstr \"閱讀有關\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"using conditional web page changes here\"\nmsgstr \"使用條件式網頁變更的快速教學\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Activate preview\"\nmsgstr \"啟用預覽\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Pro-tips:\"\nmsgstr \"專業提示：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Use the preview page to see your filters and triggers highlighted.\"\nmsgstr \"使用預覽頁面查看反白的過濾器和觸發器。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Limit trigger/ignore/block/extract to;\"\nmsgstr \"將觸發 / 忽略 / 阻擋 / 提取限制為；\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Note: Depending on the length and similarity of the text on each line, the algorithm may consider an\"\nmsgstr \"注意：根據每行文字的長度和相似度，演算法可能會將\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"instead of\"\nmsgstr \"誤判為\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"replacement\"\nmsgstr \"替換\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"for example.\"\nmsgstr \"例如。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"addition\"\nmsgstr \"新增\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"So it's always better to select\"\nmsgstr \"所以最好選擇\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"when you're interested in new content.\"\nmsgstr \"當您對新內容感興趣時。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"When content is merely moved in a list, it will also trigger an\"\nmsgstr \"當內容僅在列表中移動時，也會觸發\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"consider enabling\"\nmsgstr \"考慮啟用\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Only trigger when unique lines appear\"\nmsgstr \"僅當出現獨特行時觸發\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"Good for websites that just move the content around, and you want to know when NEW content is added, compares new \"\n\"lines against all history for this watch.\"\nmsgstr \"適用於內容僅會移動的網站，且您想知道何時新增了「新」內容，此功能會將新行與此監測任務的所有歷史記錄進行比較。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Helps reduce changes detected caused by sites shuffling lines around, combine with\"\nmsgstr \"有助於減少因網站重新排列行而檢測到的變更，結合\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"check unique lines\"\nmsgstr \"檢查獨特行\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"below.\"\nmsgstr \"於下方。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Remove any whitespace before and after each line of text\"\nmsgstr \"移除每行文字前後的所有空白\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Loading...\"\nmsgstr \"載入中 ...\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"The Visual Selector tool lets you select the\"\nmsgstr \"視覺選擇器工具讓您可以選擇\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"text\"\nmsgstr \"文字\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"\"\n\"elements that will be used for the change detection. It automatically fills-in the filters in the \"\n\"\\\"CSS/JSONPath/JQ/XPath Filters\\\" box of the\"\nmsgstr \"將用於變更檢測的元素。它會自動填入\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"tab. Use\"\nmsgstr \"分頁的「CSS / JSONPath / JQ / XPath 過濾器」欄位。使用\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Shift+Click\"\nmsgstr \"Shift + 點擊\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to select multiple items.\"\nmsgstr \"選擇多個項目。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Selection Mode:\"\nmsgstr \"選擇模式：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Select by element\"\nmsgstr \"按元素選擇\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Draw area\"\nmsgstr \"繪製區域\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear selection\"\nmsgstr \"清除選擇\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"One moment, fetching screenshot and element information..\"\nmsgstr \"請稍候，正在抓取截圖和元素資訊..\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Currently:\"\nmsgstr \"目前：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc).\"\nmsgstr \"抱歉，此功能僅適用於支援 Javascript 和截圖的抓取器（如 playwright 等）。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"to one that supports Javascript and screenshots.\"\nmsgstr \"為支援 Javascript 和截圖的方式。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Check count\"\nmsgstr \"檢查次數\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Consecutive filter failures\"\nmsgstr \"連續過濾失敗\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"History length\"\nmsgstr \"歷史長度\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Last fetch duration\"\nmsgstr \"上次抓取耗時\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Notification alert count\"\nmsgstr \"通知警報次數\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Server type reply\"\nmsgstr \"伺服器類型回應\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download latest HTML snapshot\"\nmsgstr \"下載最新 HTML 快照\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Download watch data package\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Delete Watch?\"\nmsgstr \"刪除監測任務？\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to delete the watch for:\"\nmsgstr \"您確定要刪除以下對象的監測任務：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This action cannot be undone.\"\nmsgstr \"此動作無法復原。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History?\"\nmsgstr \"清除歷史記錄？\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Are you sure you want to clear all history for:\"\nmsgstr \"您確定要清除以下項目的所有歷史記錄：\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"This will remove all snapshots and previous versions. This action cannot be undone.\"\nmsgstr \"這將移除所有快照和先前版本。此動作無法復原。\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clear History\"\nmsgstr \"清除歷史記錄\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html\nmsgid \"Clone & Edit\"\nmsgstr \"複製並編輯\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Select timestamp\"\nmsgstr \"選擇時間戳記\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Go\"\nmsgstr \"前往\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Current erroring screenshot from most recent request\"\nmsgstr \"最近請求的目前錯誤截圖\"\n\n#: changedetectionio/blueprint/ui/templates/preview.html\nmsgid \"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots.\"\nmsgstr \"截圖需要支援截圖的內容抓取器 (Sockpuppetbrowser, selenium 等)。\"\n\n#: changedetectionio/blueprint/ui/views.py\n#, python-brace-format\nmsgid \"Warning, URL {} already exists\"\nmsgstr \"警告，URL {} 已存在\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added in Paused state, saving will unpause.\"\nmsgstr \"監測任務已在暫停狀態下新增，儲存後將取消暫停。\"\n\n#: changedetectionio/blueprint/ui/views.py\nmsgid \"Watch added.\"\nmsgstr \"監測任務已新增。\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\n#, python-brace-format\nmsgid \"displaying <b>{start} - {end}</b> {record_name} in total <b>{total}</b>\"\nmsgstr \"顯示第 <b>{start} - {end}</b> 條{record_name}，共 <b>{total}</b> 條\"\n\n#: changedetectionio/blueprint/watchlist/__init__.py\nmsgid \"records\"\nmsgstr \"記錄\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changedetection.io can monitor more than just web-pages! See our plugins!\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"More info\"\nmsgstr \"更多資訊\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"You can also add 'shared' watches.\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Add a new web page change detection watch\"\nmsgstr \"新增網頁變更檢測任務\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Watch this URL!\"\nmsgstr \"監測此 URL！\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Edit first then Watch\"\nmsgstr \"先編輯後監測\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Pause\"\nmsgstr \"暫停\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnPause\"\nmsgstr \"取消暫停\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mute\"\nmsgstr \"靜音\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"UnMute\"\nmsgstr \"取消靜音\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Tag\"\nmsgstr \"標籤\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark viewed\"\nmsgstr \"標記為已讀\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Use default notification\"\nmsgstr \"使用預設通知\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear errors\"\nmsgstr \"清除錯誤\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear Histories\"\nmsgstr \"清除歷史記錄\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>您確定要清除所選項目的歷史記錄嗎？</p><p>此動作無法復原。</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"OK\"\nmsgstr \"確定\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Clear/reset history\"\nmsgstr \"清除 / 重置歷史記錄\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Delete Watches?\"\nmsgstr \"刪除監測任務？\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>\"\nmsgstr \"<p>您確定要刪除所選的監測任務嗎？</strong></p><p>此動作無法復原。</p>\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued size\"\nmsgstr \"佇列大小\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Searching\"\nmsgstr \"搜尋中\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"All\"\nmsgstr \"全部\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Website\"\nmsgstr \"網站\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Restock & Price\"\nmsgstr \"補貨與價格\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Checked\"\nmsgstr \"檢查\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Last\"\nmsgstr \"上次\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Changed\"\nmsgstr \"變更\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No web page change detection watches configured, please add a URL in the box above, or\"\nmsgstr \"未設定網站監測任務，請在上方欄位新增 URL，或\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"import a list\"\nmsgstr \"匯入列表\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Detecting restock and price\"\nmsgstr \"檢測補貨與價格\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"In stock\"\nmsgstr \"有庫存\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Not in stock\"\nmsgstr \"無庫存\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Price\"\nmsgstr \"價格\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"No information\"\nmsgstr \"無資訊\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/templates/base.html\nmsgid \"Checking now\"\nmsgstr \"正在檢查\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Queued\"\nmsgstr \"已排程\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"History\"\nmsgstr \"歷史記錄\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Preview\"\nmsgstr \"預覽\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"With errors\"\nmsgstr \"有錯誤\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Mark all viewed\"\nmsgstr \"標記所有為已讀\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"Mark all viewed in '%(title)s'\"\nmsgstr \"標記 '%(title)s' 中的所有項目為已讀\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Unread\"\nmsgstr \"未讀\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\nmsgid \"Recheck all\"\nmsgstr \"複查全部\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html\n#, python-format\nmsgid \"in '%(title)s'\"\nmsgstr \"於 '%(title)s'\"\n\n#: changedetectionio/blueprint/watchlist/templates/watch-overview.html changedetectionio/flask_app.py\n#: changedetectionio/realtime/socket_server.py\nmsgid \"Not yet\"\nmsgstr \"還沒有\"\n\n#: changedetectionio/flask_app.py\nmsgid \"0 seconds\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"year\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"years\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"month\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"months\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"week\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"weeks\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"day\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"days\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hour\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"hours\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minute\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"minutes\"\nmsgstr \"\"\n\n#: changedetectionio/flask_app.py\nmsgid \"second\"\nmsgstr \"\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/flask_app.py\nmsgid \"seconds\"\nmsgstr \"秒\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Already logged in\"\nmsgstr \"已經登入\"\n\n#: changedetectionio/flask_app.py\nmsgid \"You must be logged in, please log in.\"\nmsgstr \"您必須先登入，請登入。\"\n\n#: changedetectionio/flask_app.py\nmsgid \"Incorrect password\"\nmsgstr \"密碼錯誤\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified.\"\nmsgstr \"必須指定至少一個時間間隔（週、天、小時、分鐘或秒）。\"\n\n#: changedetectionio/forms.py\nmsgid \"At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings.\"\nmsgstr \"當不使用全域設定時，必須指定至少一個時間間隔（週、天、小時、分鐘或秒）。\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid time format. Use HH:MM.\"\nmsgstr \"時間格式無效。請使用 HH:MM。\"\n\n#: changedetectionio/forms.py\nmsgid \"Not a valid timezone name\"\nmsgstr \"不是有效的時區名稱\"\n\n#: changedetectionio/forms.py\nmsgid \"not set\"\nmsgstr \"未設定\"\n\n#: changedetectionio/forms.py\nmsgid \"Start At\"\nmsgstr \"開始於\"\n\n#: changedetectionio/forms.py\nmsgid \"Run duration\"\nmsgstr \"執行時長\"\n\n#: changedetectionio/forms.py\nmsgid \"Use time scheduler\"\nmsgstr \"使用時間排程器\"\n\n#: changedetectionio/forms.py\nmsgid \"Optional timezone to run in\"\nmsgstr \"執行時的選用時區\"\n\n#: changedetectionio/forms.py\nmsgid \"Monday\"\nmsgstr \"週一\"\n\n#: changedetectionio/forms.py\nmsgid \"Tuesday\"\nmsgstr \"週二\"\n\n#: changedetectionio/forms.py\nmsgid \"Wednesday\"\nmsgstr \"週三\"\n\n#: changedetectionio/forms.py\nmsgid \"Thursday\"\nmsgstr \"週四\"\n\n#: changedetectionio/forms.py\nmsgid \"Friday\"\nmsgstr \"週五\"\n\n#: changedetectionio/forms.py\nmsgid \"Saturday\"\nmsgstr \"週六\"\n\n#: changedetectionio/forms.py\nmsgid \"Sunday\"\nmsgstr \"週日\"\n\n#: changedetectionio/forms.py\nmsgid \"Weeks\"\nmsgstr \"週\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more seconds\"\nmsgstr \"應包含 0 或更多秒\"\n\n#: changedetectionio/forms.py\nmsgid \"Days\"\nmsgstr \"天\"\n\n#: changedetectionio/forms.py\nmsgid \"Hours\"\nmsgstr \"小時\"\n\n#: changedetectionio/forms.py\nmsgid \"Minutes\"\nmsgstr \"分鐘\"\n\n#: changedetectionio/forms.py\nmsgid \"Seconds\"\nmsgstr \"秒\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body and Title is required when a Notification URL is used\"\nmsgstr \"使用通知 URL 時，必須填寫通知內容與標題\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid AppRise URL.\"\nmsgstr \"「%s」不是有效的 AppRise URL。\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"RegEx '%s' is not a valid regular expression.\"\nmsgstr \"RegEx 「%s」不是有效的正規表示式。\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid XPath expression. (%s)\"\nmsgstr \"「%s」不是有效的 XPath 表達式 (%s)。\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid JSONPath expression. (%s)\"\nmsgstr \"「%s」不是有效的 JSONPath 表達式 (%s)。\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"'%s' is not a valid jq expression. (%s)\"\nmsgstr \"「%s」不是有效的 jq 表達式 (%s)。\"\n\n#: changedetectionio/forms.py\nmsgid \"Empty value not allowed.\"\nmsgstr \"不允許空值。\"\n\n#: changedetectionio/forms.py\nmsgid \"Invalid value.\"\nmsgstr \"數值無效。\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"URL\"\nmsgstr \"URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Group tag\"\nmsgstr \"群組 / 標籤\"\n\n#: changedetectionio/forms.py\nmsgid \"Watch\"\nmsgstr \"監測任務\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor\"\nmsgstr \"處理器\"\n\n#: changedetectionio/forms.py\nmsgid \"Edit > Watch\"\nmsgstr \"編輯 > 監測任務\"\n\n#: changedetectionio/forms.py\nmsgid \"Fetch Method\"\nmsgstr \"抓取方式\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Body\"\nmsgstr \"通知內容\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification format\"\nmsgstr \"通知格式\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification Title\"\nmsgstr \"通知標題\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification URL List\"\nmsgstr \"通知 URL 列表\"\n\n#: changedetectionio/forms.py\nmsgid \"Processor - What do you want to achieve?\"\nmsgstr \"處理器 - 您想要達成什麼目標？\"\n\n#: changedetectionio/forms.py\nmsgid \"Default timezone for watch check scheduler\"\nmsgstr \"監測排程的預設時區\"\n\n#: changedetectionio/forms.py\nmsgid \"Wait seconds before extracting text\"\nmsgstr \"提取文字前的等待秒數\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain one or more seconds\"\nmsgstr \"應包含 1 秒或更多秒\"\n\n#: changedetectionio/forms.py\nmsgid \"URLs\"\nmsgstr \"URL 列表\"\n\n#: changedetectionio/forms.py\nmsgid \"Upload .xlsx file\"\nmsgstr \"上傳 .xlsx 檔案\"\n\n#: changedetectionio/forms.py\nmsgid \"Must be .xlsx file!\"\nmsgstr \"必須是 .xlsx 檔案！\"\n\n#: changedetectionio/forms.py\nmsgid \"File mapping\"\nmsgstr \"檔案對應\"\n\n#: changedetectionio/forms.py\nmsgid \"Operation\"\nmsgstr \"操作\"\n\n#: changedetectionio/forms.py\nmsgid \"Selector\"\nmsgstr \"選擇器\"\n\n#: changedetectionio/forms.py\nmsgid \"value\"\nmsgstr \"值\"\n\n#: changedetectionio/forms.py\nmsgid \"Time Between Check\"\nmsgstr \"檢查間隔\"\n\n#: changedetectionio/forms.py\nmsgid \"Use global settings for time between check and scheduler.\"\nmsgstr \"檢查與排程時間使用全域設定。\"\n\n#: changedetectionio/forms.py\nmsgid \"CSS/JSONPath/JQ/XPath Filters\"\nmsgstr \"CSS / JSONPath / JQ / XPath 過濾器\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove elements\"\nmsgstr \"移除元素\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract text\"\nmsgstr \"提取文字\"\n\n#: changedetectionio/blueprint/imports/templates/import.html changedetectionio/forms.py\nmsgid \"Title\"\nmsgstr \"標題\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore lines containing\"\nmsgstr \"忽略包含此內容的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Request body\"\nmsgstr \"請求內容\"\n\n#: changedetectionio/forms.py\nmsgid \"Request method\"\nmsgstr \"請求方式\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore status codes (process non-2xx status codes as normal)\"\nmsgstr \"忽略狀態碼（將非 2xx 狀態碼視為正常處理）\"\n\n#: changedetectionio/forms.py\nmsgid \"Only trigger when unique lines appear in all history\"\nmsgstr \"僅當歷史記錄中出現獨特行時觸發\"\n\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Remove duplicate lines of text\"\nmsgstr \"移除重複的文字行\"\n\n#: changedetectionio/forms.py\nmsgid \"Sort text alphabetically\"\nmsgstr \"按字母順序排序文字\"\n\n#: changedetectionio/forms.py\nmsgid \"Strip ignored lines\"\nmsgstr \"移除被忽略的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Trim whitespace before and after text\"\nmsgstr \"移除文字前後的空白\"\n\n#: changedetectionio/forms.py\nmsgid \"Added lines\"\nmsgstr \"新增的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Replaced/changed lines\"\nmsgstr \"替換 / 變更的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Removed lines\"\nmsgstr \"移除的行\"\n\n#: changedetectionio/forms.py\nmsgid \"Keyword triggers - Trigger/wait for text\"\nmsgstr \"關鍵字觸發 - 觸發 / 等待文字\"\n\n#: changedetectionio/forms.py\nmsgid \"Block change-detection while text matches\"\nmsgstr \"當文字符合時，阻擋變更檢測\"\n\n#: changedetectionio/forms.py\nmsgid \"Execute JavaScript before change detection\"\nmsgstr \"在變更檢測前執行 JavaScript\"\n\n#: changedetectionio/blueprint/tags/templates/groups-overview.html changedetectionio/forms.py\nmsgid \"Save\"\nmsgstr \"儲存\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy\"\nmsgstr \"代理伺服器\"\n\n#: changedetectionio/forms.py\nmsgid \"Send a notification when the filter can no longer be found on the page\"\nmsgstr \"當頁面上找不到過濾器時發送通知\"\n\n#: changedetectionio/forms.py\nmsgid \"Muted\"\nmsgstr \"已靜音\"\n\n#: changedetectionio/forms.py\nmsgid \"On\"\nmsgstr \"開啟\"\n\n#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html\n#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/forms.py\nmsgid \"Notifications\"\nmsgstr \"通知\"\n\n#: changedetectionio/forms.py\nmsgid \"Attach screenshot to notification (where possible)\"\nmsgstr \"將截圖附加到通知中（如果支援）\"\n\n#: changedetectionio/forms.py\nmsgid \"Match\"\nmsgstr \"符合\"\n\n#: changedetectionio/forms.py\nmsgid \"Match all of the following\"\nmsgstr \"符合以下所有條件\"\n\n#: changedetectionio/forms.py\nmsgid \"Match any of the following\"\nmsgstr \"符合以下任一條件\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in list\"\nmsgstr \"在列表中使用頁面 <title>\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of history items per watch to keep\"\nmsgstr \"\"\n\n#: changedetectionio/forms.py\nmsgid \"Body must be empty when Request Method is set to GET\"\nmsgstr \"當請求方法設為 GET 時，內容必須為空\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax configuration: %(error)s\"\nmsgstr \"範本語法設定無效：%(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax: %(error)s\"\nmsgstr \"無效的範本語法：%(error)s\"\n\n#: changedetectionio/forms.py\n#, python-format\nmsgid \"Invalid template syntax in \\\"%(header)s\\\" header: %(error)s\"\nmsgstr \"「%(header)s」標頭中的範本語法無效：%(error)s\"\n\n#: changedetectionio/forms.py\nmsgid \"Name\"\nmsgstr \"名稱\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URL\"\nmsgstr \"代理伺服器 URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Proxy URLs must start with http://, https:// or socks5://\"\nmsgstr \"代理伺服器 URL 必須以 http://、https:// 或 socks5:// 開頭\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser connection URL\"\nmsgstr \"瀏覽器連線 URL\"\n\n#: changedetectionio/forms.py\nmsgid \"Browser URLs must start with wss:// or ws://\"\nmsgstr \"瀏覽器 URL 必須以 wss:// 或 ws:// 開頭\"\n\n#: changedetectionio/forms.py\nmsgid \"Plaintext requests\"\nmsgstr \"純文字請求\"\n\n#: changedetectionio/forms.py\nmsgid \"Chrome requests\"\nmsgstr \"Chrome 請求\"\n\n#: changedetectionio/forms.py\nmsgid \"Default proxy\"\nmsgstr \"預設代理伺服器\"\n\n#: changedetectionio/forms.py\nmsgid \"Random jitter seconds ± check\"\nmsgstr \"隨機抖動秒數 ± 檢查\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of fetch workers\"\nmsgstr \"抓取工作程序 (Worker) 數量\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 50\"\nmsgstr \"應介於 1 到 50 之間\"\n\n#: changedetectionio/forms.py\nmsgid \"Requests timeout in seconds\"\nmsgstr \"請求逾時（秒）\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be between 1 and 999\"\nmsgstr \"應介於 1 到 999 之間\"\n\n#: changedetectionio/forms.py\nmsgid \"Default User-Agent overrides\"\nmsgstr \"預設 User-Agent 覆寫\"\n\n#: changedetectionio/forms.py\nmsgid \"Both a name, and a Proxy URL is required.\"\nmsgstr \"名稱與代理伺服器 URL 皆為必填。\"\n\n#: changedetectionio/forms.py\nmsgid \"Open 'History' page in a new tab\"\nmsgstr \"在新分頁開啟「歷史記錄」頁面\"\n\n#: changedetectionio/forms.py\nmsgid \"Realtime UI Updates Enabled\"\nmsgstr \"已啟用即時 UI 更新\"\n\n#: changedetectionio/forms.py\nmsgid \"Favicons Enabled\"\nmsgstr \"啟用網站圖示 (Favicons)\"\n\n#: changedetectionio/forms.py\nmsgid \"Use page <title> in watch overview list\"\nmsgstr \"在監測概覽列表中使用頁面 <title>\"\n\n#: changedetectionio/forms.py\nmsgid \"API access token security check enabled\"\nmsgstr \"已啟用 API 存取權杖安全檢查\"\n\n#: changedetectionio/forms.py\nmsgid \"Notification base URL override\"\nmsgstr \"通知基礎 URL 覆寫\"\n\n#: changedetectionio/forms.py\nmsgid \"Treat empty pages as a change?\"\nmsgstr \"將空白頁面視為變更？\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore Text\"\nmsgstr \"忽略文字\"\n\n#: changedetectionio/forms.py\nmsgid \"Ignore whitespace\"\nmsgstr \"忽略空白\"\n\n#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Must be between 0 and 100\"\nmsgstr \"必須介於 0 到 100 之間\"\n\n#: changedetectionio/forms.py changedetectionio/templates/login.html\nmsgid \"Password\"\nmsgstr \"密碼\"\n\n#: changedetectionio/forms.py\nmsgid \"Pager size\"\nmsgstr \"分頁大小\"\n\n#: changedetectionio/forms.py\nmsgid \"Should be atleast zero (disabled)\"\nmsgstr \"應至少為 0（停用）\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS Content format\"\nmsgstr \"RSS 內容格式\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS <description> body built from\"\nmsgstr \"RSS <description> 內容建立自\"\n\n#: changedetectionio/forms.py\nmsgid \"RSS \\\"System default\\\" template override\"\nmsgstr \"RSS「系統預設」範本覆寫\"\n\n#: changedetectionio/forms.py\nmsgid \"Remove password\"\nmsgstr \"移除密碼\"\n\n#: changedetectionio/forms.py\nmsgid \"Render anchor tag content\"\nmsgstr \"渲染錨點標籤內容\"\n\n#: changedetectionio/forms.py\nmsgid \"Allow anonymous access to watch history page when password is enabled\"\nmsgstr \"啟用密碼時允許匿名存取監測歷史頁面\"\n\n#: changedetectionio/forms.py\nmsgid \"Hide muted watches from RSS feed\"\nmsgstr \"從 RSS Feed 中隱藏已靜音的監測任務\"\n\n#: changedetectionio/forms.py\nmsgid \"Enable RSS reader mode \"\nmsgstr \"啟用 RSS 閱讀器模式 \"\n\n#: changedetectionio/forms.py\nmsgid \"Number of changes to show in watch RSS feed\"\nmsgstr \"在 RSS Feed 中顯示的變更數量\"\n\n#: changedetectionio/forms.py\nmsgid \"Should contain zero or more attempts\"\nmsgstr \"應包含 0 次或更多嘗試\"\n\n#: changedetectionio/forms.py\nmsgid \"Number of times the filter can be missing before sending a notification\"\nmsgstr \"發送通知前允許過濾器遺失的次數\"\n\n#: changedetectionio/forms.py\nmsgid \"RegEx to extract\"\nmsgstr \"要提取的 RegEx\"\n\n#: changedetectionio/forms.py\nmsgid \"Extract as CSV\"\nmsgstr \"提取為 CSV\"\n\n#: changedetectionio/processors/extract.py\nmsgid \"No matches found while scanning all of the watch history for that RegEx.\"\nmsgstr \"掃描此 RegEx 的所有監測歷史記錄時找不到相符項目。\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\nmsgid \"Not enough history to compare. Need at least 2 snapshots.\"\nmsgstr \"歷史記錄不足以進行比較。至少需要 2 個快照。\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to load screenshots: {}\"\nmsgstr \"無法載入截圖：{}\"\n\n#: changedetectionio/processors/image_ssim_diff/difference.py\n#, python-brace-format\nmsgid \"Failed to calculate diff: {}\"\nmsgstr \"無法計算差異：{}\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box value is too long\"\nmsgstr \"邊界框數值太長\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box must be in format: x,y,width,height (integers only)\"\nmsgstr \"邊界框格式必須為：x,y,width,height（僅限整數）\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values must be non-negative\"\nmsgstr \"邊界框數值必須為非負數\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding box values are too large\"\nmsgstr \"邊界框數值太大\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode must be either \\\"element\\\" or \\\"draw\\\"\"\nmsgstr \"選擇模式必須是 \\\"element\\\" 或 \\\"draw\\\"\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Minimum Change Percentage\"\nmsgstr \"最小變更百分比\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Pixel Difference Sensitivity\"\nmsgstr \"像素差異靈敏度\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Use global default\"\nmsgstr \"使用全域預設值\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Bounding Box\"\nmsgstr \"邊界框\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection Mode\"\nmsgstr \"選擇模式\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Selection mode value is too long\"\nmsgstr \"選擇模式數值太長\"\n\n#: changedetectionio/processors/image_ssim_diff/forms.py\nmsgid \"Screenshot Comparison\"\nmsgstr \"截圖比對\"\n\n#: changedetectionio/processors/image_ssim_diff/preview.py\nmsgid \"Preview unavailable - No snapshots captured yet\"\nmsgstr \"無法預覽 - 尚未擷取快照\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Visual / Image screenshot change detection\"\nmsgstr \"視覺 / 圖片截圖變更檢測\"\n\n#: changedetectionio/processors/image_ssim_diff/processor.py\nmsgid \"Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM\"\nmsgstr \"使用快速 OpenCV 演算法比對截圖，比 SSIM 快 10-100 倍\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Re-stock detection\"\nmsgstr \"補貨檢測\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"In Stock only (Out Of Stock -> In Stock only)\"\nmsgstr \"僅有庫存（缺貨 -> 僅有庫存）\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Any availability changes\"\nmsgstr \"任何可用性變更\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Off, don't follow availability/restock\"\nmsgstr \"關閉，不追蹤可用性 / 補貨\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Below price to trigger notification\"\nmsgstr \"低於此價格觸發通知\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"No limit\"\nmsgstr \"無限制\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Above price to trigger notification\"\nmsgstr \"高於此價格觸發通知\"\n\n#: changedetectionio/processors/restock_diff/forms.py\n#, python-format\nmsgid \"Threshold in %% for price changes since the original price\"\nmsgstr \"自原始價格以來價格變化的閾值（%%）\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Should be between 0 and 100\"\nmsgstr \"應介於 0 到 100 之間\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Follow price changes\"\nmsgstr \"追蹤價格變化\"\n\n#: changedetectionio/processors/restock_diff/forms.py\nmsgid \"Restock & Price Detection\"\nmsgstr \"補貨與價格檢測\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Re-stock & Price detection for pages with a SINGLE product\"\nmsgstr \"針對單一產品頁面的補貨與價格檢測\"\n\n#: changedetectionio/processors/restock_diff/processor.py\nmsgid \"Detects if the product goes back to in-stock\"\nmsgstr \"檢測產品是否恢復庫存\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Webpage Text/HTML, JSON and PDF changes\"\nmsgstr \"網頁文字 / HTML、JSON 和 PDF 變更\"\n\n#: changedetectionio/processors/text_json_diff/processor.py\nmsgid \"Detects all text changes where possible\"\nmsgstr \"盡可能檢測所有文字變更\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Error fetching metadata for {}\"\nmsgstr \"讀取 {} 的中繼資料時發生錯誤\"\n\n#: changedetectionio/store/__init__.py\nmsgid \"Watch protocol is not permitted or invalid URL format\"\nmsgstr \"監測協定不被允許或 URL 格式無效\"\n\n#: changedetectionio/store/__init__.py\n#, python-brace-format\nmsgid \"Watch limit reached ({}/{} watches). Cannot add more watches.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Body for all notifications — You can use\"\nmsgstr \"所有通知的內文 — 您可以使用\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"templating in the notification title, body and URL, and tokens from below.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show token/placeholders\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Token\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Description\"\nmsgstr \"描述\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the changedetection.io instance you are running.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL being watched.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The UUID of the watch.\"\nmsgstr \"監測任務的 UUID。\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The page title of the watch, uses <title> if not set, falls back to URL\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The watch group / tag\"\nmsgstr \"群組 / 標籤\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the preview page generated by changedetection.io.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The URL of the diff output for the watch.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes, additions, and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Without (added) prefix or colors\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and additions —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - only changes and removals —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - full difference output —\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The diff output - patch in unified format\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"The current snapshot text contents value, useful when combined with JSON or CSS filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Text that tripped the trigger from filters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Warning: Contents of\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"and\"\nmsgstr \"和\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"depend on how the difference algorithm perceives the change.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For example, an addition or removal could be perceived as a change in some cases.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"More Here\"\nmsgstr \"更多資訊\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"AppRise Notification URLs\"\nmsgstr \"AppRise 通知 URL\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for notification to just about any service!\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Please read the notification services wiki here for important configuration notes\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html changedetectionio/templates/edit/text-options.html\nmsgid \"Use\"\nmsgstr \"使用\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Show advanced help and tips\"\nmsgstr \"顯示進階說明與提示\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports a maximum\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"2,000 characters\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"of notification text, including the title.\"\nmsgstr \"通知標題\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"bots can't send messages to other bots, so you should specify chat ID of non-bot user.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"only supports very limited HTML and can fail when extra tags are sent,\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"(or use plaintext/markdown format)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for direct API calls (or omit the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for non-SSL ie\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"more help here\"\nmsgstr \"更多幫助和範例請見此處\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Accepts the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"placeholders listed below\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Send test notification\"\nmsgstr \"發送測試通知\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add email\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Add an email address\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Notification debug logs\"\nmsgstr \"通知除錯記錄\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Processing..\"\nmsgstr \"處理中..\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Title for all notifications\"\nmsgstr \"所有通知的標題\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For JSON payloads, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"without quotes for automatic escaping, for example -\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"URL encoding, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"for example -\"\nmsgstr \"例如 -\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Regular-expression replace, use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"For a complete reference of all Jinja2 built-in filters, users can refer to the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_common_fields.html\nmsgid \"Format for all notifications\"\nmsgstr \"所有通知的格式\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Entry\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Actions\"\nmsgstr \"操作\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Add a row/rule after\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Remove this row/rule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Verify this rule against current snapshot\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Alternatively try our\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"very affordable subscription based service which has all this setup for you\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"You may need to\"\nmsgstr \"您可能需要\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Enable playwright environment variable\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"and uncomment the\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"in the\"\nmsgstr \"在\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"file\"\nmsgstr \"檔案\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Set a hourly/week day schedule\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Schedule time limits\"\nmsgstr \"排程時間限制\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Business hours\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Weekends\"\nmsgstr \"週末\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Reset\"\nmsgstr \"重設\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"This could have unintended consequences.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"More help and examples about using the scheduler\"\nmsgstr \"更多幫助和範例請見此處\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Want to use a time schedule?\"\nmsgstr \"使用時間排程器\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"First confirm/save your Time Zone Settings\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggers a change if this text appears, AND something changed in the document.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Triggered text\"\nmsgstr \"觸發文字\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored for calculating changes, but still shown.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Ignored text\"\nmsgstr \"忽略文字\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"No change-detection will occur because this text exists.\"\nmsgstr \"當文字符合時，阻擋變更檢測\"\n\n#: changedetectionio/templates/_helpers.html\nmsgid \"Blocked text\"\nmsgstr \"阻擋文字\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search, or Use Alt+S Key\"\nmsgstr \"搜尋，或使用 Alt+S 鍵\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Real-time updates offline\"\nmsgstr \"離線即時更新\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Select Language\"\nmsgstr \"選擇語言\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Auto-detect from browser\"\nmsgstr \"從瀏覽器自動檢測\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Language support is in beta, please help us improve by opening a PR on GitHub with any updates.\"\nmsgstr \"語言支援尚在 Beta 階段，請在 GitHub 上提交 PR 以協助我們改進。\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Search\"\nmsgstr \"搜尋\"\n\n#: changedetectionio/templates/base.html\nmsgid \"URL or Title\"\nmsgstr \"\"\n\n#: changedetectionio/templates/base.html\nmsgid \"in\"\nmsgstr \"在\"\n\n#: changedetectionio/templates/base.html\nmsgid \"Enter search term...\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Each line is processed separately (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Note: Wrap in forward slash / to use regex example:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"You can also use\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"conditions\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\\\"Page text\\\" - with Contains, Starts With, Not Contains and many more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"\"\n\"Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for \"\n\"waiting for when a product is available again\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"All lines here must not exist (think of each line as \\\"OR\\\")\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Extracts text in the final output (line by line) after other filters using regular expressions or string match:\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Regular expression - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Don't forget to consider the white-space at the start of a line\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"type flags (more\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"information here\"\nmsgstr \"此處資訊\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Keyword example - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Use groups to extract just that text - example\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"returns a list of years only\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"Example - match lines containing a keyword\"\nmsgstr \"\"\n\n#: changedetectionio/templates/edit/text-options.html\nmsgid \"One line per regular-expression/string match\"\nmsgstr \"\"\n\n#: changedetectionio/templates/login.html\nmsgid \"Login\"\nmsgstr \"登入\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"GROUPS\"\nmsgstr \"群組\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"SETTINGS\"\nmsgstr \"設定\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"IMPORT\"\nmsgstr \"匯入\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Resume automatic scheduling\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Pause auto-queue scheduling of watches\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Scheduling is paused - click to resume\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Unmute notifications\"\nmsgstr \"取消靜音通知\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Mute notifications\"\nmsgstr \"靜音通知\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Notifications are muted - click to unmute\"\nmsgstr \"通知已靜音 - 點擊以取消靜音\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"EDIT\"\nmsgstr \"編輯\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"LOG OUT\"\nmsgstr \"登出\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Website Change Detection and Notification.\"\nmsgstr \"\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle Light/Dark Mode\"\nmsgstr \"切換亮 / 暗模式\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Toggle light/dark mode\"\nmsgstr \"切換亮 / 暗模式\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change Language\"\nmsgstr \"更改語言\"\n\n#: changedetectionio/templates/menu.html\nmsgid \"Change language\"\nmsgstr \"更改語言\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Yes\"\nmsgstr \"是\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"No\"\nmsgstr \"否\"\n\n#: changedetectionio/widgets/ternary_boolean.py\nmsgid \"Main settings\"\nmsgstr \"主設定\"\n\n#~ msgid \"Entry\"\n#~ msgstr \"項目\"\n\n#~ msgid \"Actions\"\n#~ msgstr \"動作\"\n\n#~ msgid \"Add a row/rule after\"\n#~ msgstr \"在後方新增一行 / 規則\"\n\n#~ msgid \"Remove this row/rule\"\n#~ msgstr \"移除此行 / 規則\"\n\n#~ msgid \"Verify this rule against current snapshot\"\n#~ msgstr \"針對目前快照驗證此規則\"\n\n#~ msgid \"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled.\"\n#~ msgstr \"錯誤 - 此監測任務需要 Chrome（搭配 playwright / sockpuppetbrowser），但未啟用基於 Chrome 的抓取功能。\"\n\n#~ msgid \"Alternatively try our\"\n#~ msgstr \"或者嘗試我們\"\n\n#~ msgid \"very affordable subscription based service which has all this setup for you\"\n#~ msgstr \"非常實惠的訂閱服務，為您準備好所有設定\"\n\n#~ msgid \"You may need to\"\n#~ msgstr \"您可能需要\"\n\n#~ msgid \"Enable playwright environment variable\"\n#~ msgstr \"啟用 playwright 環境變數\"\n\n#~ msgid \"and uncomment the\"\n#~ msgstr \"並取消註解\"\n\n#~ msgid \"in the\"\n#~ msgstr \"於\"\n\n#~ msgid \"file\"\n#~ msgstr \"檔案\"\n\n#~ msgid \"Set a hourly/week day schedule\"\n#~ msgstr \"設定每小時 / 平日排程\"\n\n#~ msgid \"Schedule time limits\"\n#~ msgstr \"排程時間限制\"\n\n#~ msgid \"Business hours\"\n#~ msgstr \"營業時間\"\n\n#~ msgid \"Weekends\"\n#~ msgstr \"週末\"\n\n#~ msgid \"Reset\"\n#~ msgstr \"重置\"\n\n#~ msgid \"Warning, one or more of your 'days' has a duration that would extend into the next day.\"\n#~ msgstr \"警告，您設定的一個或多個「天」持續時間將延伸至隔天。\"\n\n#~ msgid \"This could have unintended consequences.\"\n#~ msgstr \"這可能會產生非預期的後果。\"\n\n#~ msgid \"More help and examples about using the scheduler\"\n#~ msgstr \"關於使用排程器的更多幫助和範例\"\n\n#~ msgid \"Want to use a time schedule?\"\n#~ msgstr \"想要使用時間排程嗎？\"\n\n#~ msgid \"First confirm/save your Time Zone Settings\"\n#~ msgstr \"請先確認 / 儲存您的時區設定\"\n\n#~ msgid \"Triggers a change if this text appears, AND something changed in the document.\"\n#~ msgstr \"如果出現此文字，且文件中有些內容變更，則觸發變更。\"\n\n#~ msgid \"Triggered text\"\n#~ msgstr \"觸發的文字\"\n\n#~ msgid \"Ignored for calculating changes, but still shown.\"\n#~ msgstr \"計算變更時忽略，但仍會顯示。\"\n\n#~ msgid \"Ignored text\"\n#~ msgstr \"忽略的文字\"\n\n#~ msgid \"No change-detection will occur because this text exists.\"\n#~ msgstr \"因為存在此文字，將不會進行變更檢測。\"\n\n#~ msgid \"Blocked text\"\n#~ msgstr \"被阻擋的文字\"\n\n#~ msgid \"Search\"\n#~ msgstr \"搜尋\"\n\n#~ msgid \"URL or Title\"\n#~ msgstr \"URL 或標題\"\n\n#~ msgid \"in\"\n#~ msgstr \"於\"\n\n#~ msgid \"Enter search term...\"\n#~ msgstr \"輸入搜尋關鍵字 ...\"\n\n#~ msgid \"Watch List\"\n#~ msgstr \"監測列表\"\n\n#~ msgid \"Watches\"\n#~ msgstr \"監測任務\"\n\n#~ msgid \"Queue Status\"\n#~ msgstr \"佇列狀態\"\n\n#~ msgid \"Queue\"\n#~ msgstr \"佇列\"\n\n#~ msgid \"Sitemap Crawler\"\n#~ msgstr \"Sitemap 爬蟲\"\n\n#~ msgid \"Sitemap\"\n#~ msgstr \"Sitemap\"\n\n#~ msgid \"Tag unlinked removed from {} watches\"\n#~ msgstr \"標籤已取消連結，並從 {} 個監測任務中移除\"\n\n#~ msgid \"All tags deleted\"\n#~ msgstr \"所有標籤已刪除\"\n\n#~ msgid \"Cleared snapshot history for all watches\"\n#~ msgstr \"已清除所有監測任務的快照歷史記錄\"\n\n#~ msgid \"No watches available to recheck.\"\n#~ msgstr \"沒有可複查的監測任務。\"\n\n#~ msgid \"Cannot load the edit form for processor/plugin '{}', plugin missing?\"\n#~ msgstr \"無法載入處理器 / 外掛 '{}' 的編輯表單，外掛是否遺失？\"\n\n#~ msgid \"Create a shareable link\"\n#~ msgstr \"建立可分享連結\"\n\n#~ msgid \"Tip: You can also add 'shared' watches.\"\n#~ msgstr \"提示：您也可以新增「共享」監測任務。\"\n\n#~ msgid \"Marking watches as viewed in background...\"\n#~ msgstr \"\"\n\n"
  },
  {
    "path": "changedetectionio/validate_url.py",
    "content": "import ipaddress\nimport socket\nfrom functools import lru_cache\nfrom loguru import logger\nfrom urllib.parse import urlparse, urlunparse, parse_qsl, urlencode\n\n\ndef normalize_url_encoding(url):\n    \"\"\"\n    Safely encode a URL's query parameters, regardless of whether they're already encoded.\n\n    Why this is necessary:\n    URLs can arrive in various states - some with already encoded query parameters (%20 for spaces),\n    some with unencoded parameters (literal spaces), or a mix of both. The validators.url() function\n    requires proper encoding, but simply encoding an already-encoded URL would double-encode it\n    (e.g., %20 would become %2520).\n\n    This function solves the problem by:\n    1. Parsing the URL to extract query parameters\n    2. parse_qsl() automatically decodes parameters if they're encoded\n    3. urlencode() re-encodes them properly\n    4. Returns a consistently encoded URL that will pass validation\n\n    Example:\n    - Input:  \"http://example.com/test?time=2025-10-28 09:19\"  (space not encoded)\n    - Output: \"http://example.com/test?time=2025-10-28+09%3A19\" (properly encoded)\n\n    - Input:  \"http://example.com/test?time=2025-10-28%2009:19\" (already encoded)\n    - Output: \"http://example.com/test?time=2025-10-28+09%3A19\" (properly encoded)\n\n    Returns a properly encoded URL string.\n    \"\"\"\n    try:\n        # Parse the URL into components (scheme, netloc, path, params, query, fragment)\n        parsed = urlparse(url)\n\n        # Parse query string - this automatically decodes it if encoded\n        # parse_qsl handles both encoded and unencoded query strings gracefully\n        query_params = parse_qsl(parsed.query, keep_blank_values=True)\n\n        # Re-encode the query string properly using standard URL encoding\n        encoded_query = urlencode(query_params, safe='')\n\n        # Reconstruct the URL with properly encoded query string\n        normalized = urlunparse((\n            parsed.scheme,\n            parsed.netloc,\n            parsed.path,\n            parsed.params,\n            encoded_query,  # Use the re-encoded query\n            parsed.fragment\n        ))\n\n        return normalized\n    except Exception as e:\n        # If parsing fails for any reason, return original URL\n        logger.debug(f\"URL normalization failed for '{url}': {e}\")\n        return url\n\n\ndef is_private_hostname(hostname):\n    \"\"\"Return True if hostname resolves to an IANA-restricted (private/reserved) IP address.\n\n    Unresolvable hostnames return False (allow them) — DNS may be temporarily unavailable\n    or the domain not yet live. The actual DNS rebinding attack is mitigated by fetch-time\n    re-validation in requests.py, not by blocking unresolvable domains at add-time.\n    Never cached — callers that need fresh DNS resolution (e.g. at fetch time) can call\n    this directly without going through the lru_cached is_safe_valid_url().\n    \"\"\"\n    try:\n        for info in socket.getaddrinfo(hostname, None):\n            ip = ipaddress.ip_address(info[4][0])\n            if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:\n                logger.warning(f\"Hostname '{hostname} - {ip} - ip.is_private = {ip.is_private}, ip.is_loopback = {ip.is_loopback}, ip.is_link_local = {ip.is_link_local}, ip.is_reserved = {ip.is_reserved}\")\n                return True\n    except socket.gaierror as e:\n        logger.warning(f\"{hostname} error checking {str(e)}\")\n        return False\n    logger.info(f\"Hostname '{hostname}' is NOT private/IANA restricted.\")\n    return False\n\n\ndef is_safe_valid_url(test_url):\n    from changedetectionio import strtobool\n    from changedetectionio.jinja2_custom import render as jinja_render\n    import os\n    import re\n    import validators\n\n    # Validate input type first - must be a non-empty string\n    if test_url is None:\n        logger.warning('URL validation failed: URL is None')\n        return False\n\n    if not isinstance(test_url, str):\n        logger.warning(f'URL validation failed: URL must be a string, got {type(test_url).__name__}')\n        return False\n\n    if not test_url.strip():\n        logger.warning('URL validation failed: URL is empty or whitespace only')\n        return False\n\n    # Per-request cache: same URL is often validated 2-3x per watchlist render (sort + display).\n    # Flask's g is scoped to one request and auto-cleared on teardown, so dynamic Jinja2 URLs\n    # like {{microtime()}} are always re-evaluated on the next request.\n    # Falls back gracefully when called outside a request context (e.g. background workers).\n    _cache_key = test_url\n    try:\n        from flask import g\n        _cache = g.setdefault('_url_validation_cache', {})\n        if _cache_key in _cache:\n            return _cache[_cache_key]\n    except RuntimeError:\n        _cache = None  # No app context\n\n    allow_file_access = strtobool(os.getenv('ALLOW_FILE_URI', 'false'))\n    safe_protocol_regex = '^(http|https|ftp|file):' if allow_file_access else '^(http|https|ftp):'\n\n    # See https://github.com/dgtlmoon/changedetection.io/issues/1358\n\n    # Remove 'source:' prefix so we dont get 'source:javascript:' etc\n    # 'source:' is a valid way to tell us to return the source\n\n    r = re.compile('^source:', re.IGNORECASE)\n    test_url = r.sub('', test_url)\n\n    # Check the actual rendered URL in case of any Jinja markup\n    # Only run jinja_render when the URL actually contains Jinja2 syntax - creating a new\n    # ImmutableSandboxedEnvironment is expensive and is called once per watch per page load\n    if '{%' in test_url or '{{' in test_url:\n        try:\n            test_url = jinja_render(test_url)\n        except Exception as e:\n            logger.error(f'URL \"{test_url}\" is not correct Jinja2? {str(e)}')\n            return False\n\n    # Check query parameters and fragment\n    if re.search(r'[<>]', test_url):\n        logger.warning(f'URL \"{test_url}\" contains suspicious characters')\n        return False\n\n    # Normalize URL encoding - handle both encoded and unencoded query parameters\n    test_url = normalize_url_encoding(test_url)\n\n    # Be sure the protocol is safe (no file, etcetc)\n    pattern = re.compile(os.getenv('SAFE_PROTOCOL_REGEX', safe_protocol_regex), re.IGNORECASE)\n    if not pattern.match(test_url.strip()):\n        logger.warning(f'URL \"{test_url}\" is not safe, aborting.')\n        return False\n\n    # If hosts that only contain alphanumerics are allowed (\"localhost\" for example)\n    allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))\n    try:\n        if not test_url.strip().lower().startswith('file:') and not validators.url(test_url, simple_host=allow_simplehost):\n            logger.warning(f'URL \"{test_url}\" failed validation, aborting.')\n            return False\n    except validators.ValidationError:\n        logger.warning(f'URL f\"{test_url}\" failed validation, aborting.')\n        return False\n\n    if _cache is not None:\n        _cache[_cache_key] = True\n    return True\n"
  },
  {
    "path": "changedetectionio/widgets/__init__.py",
    "content": "from .ternary_boolean import TernaryNoneBooleanWidget, TernaryNoneBooleanField\n\n__all__ = ['TernaryNoneBooleanWidget', 'TernaryNoneBooleanField']"
  },
  {
    "path": "changedetectionio/widgets/ternary_boolean.py",
    "content": "from wtforms import Field\nfrom markupsafe import Markup\nfrom flask_babel import lazy_gettext as _l\n\nclass TernaryNoneBooleanWidget:\n    \"\"\"\n    A widget that renders a horizontal radio button group with either two options (Yes/No)\n    or three options (Yes/No/Default), depending on the field's boolean_mode setting.\n    \"\"\"\n    def __call__(self, field, **kwargs):\n        html = ['<div class=\"ternary-radio-group pure-form\">']\n        \n        field_id = kwargs.pop('id', field.id)\n        boolean_mode = getattr(field, 'boolean_mode', False)\n        \n        # Get custom text or use defaults\n        yes_text = getattr(field, 'yes_text', _l('Yes'))\n        no_text = getattr(field, 'no_text', _l('No'))\n        none_text = getattr(field, 'none_text', _l('Main settings'))\n        \n        # True option\n        checked_true = ' checked' if field.data is True else ''\n        html.append(f'''\n            <label class=\"ternary-radio-option\">\n                <input type=\"radio\" name=\"{field.name}\" value=\"true\" id=\"{field_id}_true\"{checked_true} class=\"pure-radio\">\n                <span class=\"ternary-radio-label pure-button-primary\">{yes_text}</span>\n            </label>\n        ''')\n        \n        # False option  \n        checked_false = ' checked' if field.data is False else ''\n        html.append(f'''\n            <label class=\"ternary-radio-option\">\n                <input type=\"radio\" name=\"{field.name}\" value=\"false\" id=\"{field_id}_false\"{checked_false} class=\"pure-radio\">\n                <span class=\"ternary-radio-label\">{no_text}</span>\n            </label>\n        ''')\n        \n        # None option (only show if not in boolean mode)\n        if not boolean_mode:\n            checked_none = ' checked' if field.data is None else ''\n            html.append(f'''\n                <label class=\"ternary-radio-option\">\n                    <input type=\"radio\" name=\"{field.name}\" value=\"none\" id=\"{field_id}_none\"{checked_none} class=\"pure-radio\">\n                    <span class=\"ternary-radio-label ternary-default\">{none_text}</span>\n                </label>\n            ''')\n        \n        html.append('</div>')\n\n        return Markup(''.join(html))\n\nclass TernaryNoneBooleanField(Field):\n    \"\"\"\n    A field that can handle True, False, or None values, represented as a horizontal radio group.\n    When boolean_mode=True, it acts like a BooleanField (only Yes/No options).\n    When boolean_mode=False (default), it shows Yes/No/Default options.\n    \n    Custom text can be provided for each option:\n    - yes_text: Text for True option (default: \"Yes\")\n    - no_text: Text for False option (default: \"No\")  \n    - none_text: Text for None option (default: \"Default\")\n    \"\"\"\n    widget = TernaryNoneBooleanWidget()\n    \n    def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False,\n                 yes_text=None, no_text=None, none_text=None, **kwargs):\n        super(TernaryNoneBooleanField, self).__init__(label, validators, **kwargs)\n        \n        self.boolean_mode = boolean_mode\n        self.yes_text = yes_text if yes_text is not None else _l('Yes')\n        self.no_text = no_text if no_text is not None else _l('No')\n        self.none_text = none_text if none_text is not None else _l('Main settings')\n        \n        if false_values is None:\n            self.false_values = {'false', ''}\n        else:\n            self.false_values = false_values\n\n    def process_formdata(self, valuelist):\n        if not valuelist or not valuelist[0]:\n            # In boolean mode, default to False instead of None\n            self.data = False if self.boolean_mode else None\n        elif valuelist[0].lower() == 'true':\n            self.data = True\n        elif valuelist[0].lower() == 'false':\n            self.data = False\n        elif valuelist[0].lower() == 'none':\n            # In boolean mode, treat 'none' as False\n            self.data = False if self.boolean_mode else None\n        else:\n            self.data = False if self.boolean_mode else None\n\n    def _value(self):\n        if self.data is True:\n            return 'true'\n        elif self.data is False:\n            return 'false'\n        else:\n            # In boolean mode, None should be treated as False\n            if self.boolean_mode:\n                return 'false'\n            else:\n                return 'none'"
  },
  {
    "path": "changedetectionio/widgets/test_custom_text.py",
    "content": "#!/usr/bin/env python3\n\nimport sys\nimport os\n\nfrom changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH\n\nsys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))\n\nfrom changedetectionio.widgets import TernaryNoneBooleanField\nfrom wtforms import Form\n\nclass TestForm(Form):\n    # Default text\n    default_field = TernaryNoneBooleanField('Default Field', default=None)\n    \n    # Custom text with HTML icons\n    notification_field = TernaryNoneBooleanField(\n        'Notifications',\n        default=False,\n        yes_text='🔕 Muted', \n        no_text='🔔 Unmuted', \n        none_text='⚙️ System default'\n    )\n    \n    # HTML with styling\n    styled_field = TernaryNoneBooleanField(\n        'Status',\n        default=None,\n        yes_text='<strong style=\"color: green;\">✅ Active</strong>',\n        no_text='<strong style=\"color: red;\">❌ Inactive</strong>',\n        none_text='<em style=\"color: gray;\">🔧 Auto</em>'\n    )\n    \n    # Boolean mode with custom text\n    boolean_field = TernaryNoneBooleanField(\n        'Boolean Field', \n        default=True,\n        boolean_mode=True,\n        yes_text=\"Enabled\",\n        no_text=\"Disabled\"\n    )\n    \n    # FontAwesome example\n    fontawesome_field = TernaryNoneBooleanField(\n        'Notifications with FontAwesome',\n        default=None,\n        yes_text='<i class=\"fa fa-bell-slash\"></i> Muted',\n        no_text='<i class=\"fa fa-bell\"></i> Unmuted',\n        none_text='<i class=\"fa fa-cogs\"></i> System default'\n    )\n\ndef test_custom_text():\n    \"\"\"Test custom text functionality\"\"\"\n    \n    form = TestForm()\n    \n    print(\"=== Testing TernaryNoneBooleanField Custom Text ===\")\n    \n    # Test default field\n    print(\"\\n--- Default Field ---\")\n    default_field = form.default_field\n    default_html = default_field.widget(default_field)\n    print(f\"Contains 'Yes': {'Yes' in default_html}\")\n    print(f\"Contains 'No': {'No' in default_html}\")\n    print(f\"Contains 'Default': {'Default' in default_html}\")\n    assert 'Yes' in default_html and 'No' in default_html and 'Default' in default_html\n    \n    # Test custom text field\n    print(\"\\n--- Custom Text Field with Emojis ---\")\n    notification_field = form.notification_field\n    notification_html = notification_field.widget(notification_field)\n    print(f\"Contains '🔕 Muted': {'🔕 Muted' in notification_html}\")\n    print(f\"Contains '🔔 Unmuted': {'🔔 Unmuted' in notification_html}\")\n    print(f\"Contains '⚙️ System default': {'⚙️ System default' in notification_html}\")\n    print(f\"Does NOT contain 'Yes': {'Yes' not in notification_html}\")\n    print(f\"Does NOT contain 'No': {'No' not in notification_html}\")\n    assert '🔕 Muted' in notification_html and '🔔 Unmuted' in notification_html\n    assert 'Yes' not in notification_html and 'No' not in notification_html\n    \n    # Test HTML styling\n    print(\"\\n--- HTML Styled Field ---\")\n    styled_field = form.styled_field\n    styled_html = styled_field.widget(styled_field)\n    print(f\"Contains HTML tags: {'<strong' in styled_html}\")\n    print(f\"Contains color styling: {'color: green' in styled_html}\")\n    print(f\"Contains emojis: {'✅' in styled_html and '❌' in styled_html}\")\n    assert '<strong' in styled_html and 'color: green' in styled_html\n    \n    # Test boolean mode with custom text\n    print(\"\\n--- Boolean Field with Custom Text ---\")\n    boolean_field = form.boolean_field\n    boolean_html = boolean_field.widget(boolean_field)\n    print(f\"Contains 'Enabled': {'Enabled' in boolean_html}\")\n    print(f\"Contains 'Disabled': {'Disabled' in boolean_html}\")\n    print(f\"Does NOT contain 'System default': {'System default' not in boolean_html}\")\n    print(f\"Does NOT contain 'Default': {'Default' not in boolean_html}\")\n    assert 'Enabled' in boolean_html and 'Disabled' in boolean_html\n    assert USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH not in boolean_html and 'Default' not in boolean_html\n    \n    # Test FontAwesome field\n    print(\"\\n--- FontAwesome Icons Field ---\")\n    fontawesome_field = form.fontawesome_field\n    fontawesome_html = fontawesome_field.widget(fontawesome_field)\n    print(f\"Contains FontAwesome classes: {'fa fa-bell' in fontawesome_html}\")\n    print(f\"Contains multiple FA icons: {'fa fa-bell-slash' in fontawesome_html and 'fa fa-cogs' in fontawesome_html}\")\n    assert 'fa fa-bell' in fontawesome_html\n    \n    print(\"\\n✅ All custom text tests passed!\")\n    print(\"\\n--- Example Usage ---\")\n    print(\"TernaryNoneBooleanField('Status', yes_text='🟢 Online', no_text='🔴 Offline', none_text='🟡 Auto')\")\n    print(\"TernaryNoneBooleanField('Notifications', yes_text='<i class=\\\"fa fa-bell-slash\\\"></i> Muted', ...)\")\n\ndef test_data_processing():\n    \"\"\"Test that custom text doesn't affect data processing\"\"\"\n    print(\"\\n=== Testing Data Processing ===\")\n    \n    form = TestForm()\n    field = form.notification_field\n    \n    # Test form data processing\n    field.process_formdata(['true'])\n    assert field.data is True, \"Custom text should not affect data processing\"\n    print(\"✅ True processing works with custom text\")\n    \n    field.process_formdata(['false'])\n    assert field.data is False, \"Custom text should not affect data processing\"\n    print(\"✅ False processing works with custom text\")\n    \n    field.process_formdata(['none'])\n    assert field.data is None, \"Custom text should not affect data processing\"\n    print(\"✅ None processing works with custom text\")\n    \n    print(\"✅ All data processing tests passed!\")\n\nif __name__ == '__main__':\n    test_custom_text()\n    test_data_processing()"
  },
  {
    "path": "changedetectionio/worker.py",
    "content": "from blinker import signal\nfrom .processors.exceptions import ProcessorException\nimport changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions\nfrom changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse\nfrom changedetectionio import html_tools\nfrom changedetectionio import worker_pool\nfrom changedetectionio.queuedWatchMetaData import PrioritizedItem\nfrom changedetectionio.pluggy_interface import apply_update_handler_alter, apply_update_finalize\n\nimport asyncio\nimport os\nimport sys\nimport time\n\nfrom loguru import logger\n\n# Async version of update_worker\n# Processes jobs from AsyncSignalPriorityQueue instead of threaded queue\n\nIN_PYTEST = \"pytest\" in sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ\nDEFER_SLEEP_TIME_ALREADY_QUEUED = 0.3 if IN_PYTEST else 10.0\n\nasync def async_update_worker(worker_id, q, notification_q, app, datastore, executor=None):\n    \"\"\"\n    Async worker function that processes watch check jobs from the queue.\n\n    Args:\n        worker_id: Unique identifier for this worker\n        q: AsyncSignalPriorityQueue containing jobs to process\n        notification_q: Standard queue for notifications\n        app: Flask application instance\n        datastore: Application datastore\n        executor: ThreadPoolExecutor for queue operations (optional)\n\n    Returns:\n        \"restart\" if worker should restart, \"shutdown\" for clean exit\n    \"\"\"\n    # Set a descriptive name for this task\n    task = asyncio.current_task()\n    if task:\n        task.set_name(f\"async-worker-{worker_id}\")\n\n    # Read restart policy from environment\n    max_jobs = int(os.getenv(\"WORKER_MAX_JOBS\", \"10\"))\n    max_runtime_seconds = int(os.getenv(\"WORKER_MAX_RUNTIME\", \"3600\"))  # 1 hour default\n\n    jobs_processed = 0\n    start_time = time.time()\n\n    # Log thread name for debugging\n    import threading\n    thread_name = threading.current_thread().name\n    logger.info(f\"Starting async worker {worker_id} on thread '{thread_name}' (max_jobs={max_jobs}, max_runtime={max_runtime_seconds}s)\")\n\n    while not app.config.exit.is_set():\n        update_handler = None\n        watch = None\n        processing_exception = None  # Reset at start of each iteration to prevent state bleeding\n\n        try:\n            # Efficient blocking via run_in_executor (no polling overhead!)\n            # Worker blocks in threading.Queue.get() which uses Condition.wait()\n            # Executor must be sized to match worker count (see worker_pool.py: 50 threads default)\n            # Single timeout (no double-timeout wrapper) = no race condition\n            queued_item_data = await q.async_get(executor=executor, timeout=1.0)\n\n            # CRITICAL: Claim UUID immediately after getting from queue to prevent race condition\n            # in wait_for_all_checks() which checks qsize() and running_uuids separately\n            uuid = queued_item_data.item.get('uuid')\n            if not worker_pool.claim_uuid_for_processing(uuid, worker_id):\n                # Already being processed - re-queue and continue\n                logger.trace(f\"Worker {worker_id} detected UUID {uuid} already processing during claim - deferring\")\n                await asyncio.sleep(DEFER_SLEEP_TIME_ALREADY_QUEUED)\n                deferred_priority = max(1000, queued_item_data.priority * 10)\n                deferred_item = PrioritizedItem(priority=deferred_priority, item=queued_item_data.item)\n                worker_pool.queue_item_async_safe(q, deferred_item, silent=True)\n                continue\n\n        except asyncio.TimeoutError:\n            # No jobs available - check if we should restart based on time while idle\n            runtime = time.time() - start_time\n            if runtime >= max_runtime_seconds:\n                logger.info(f\"Worker {worker_id} idle and reached max runtime ({runtime:.0f}s), restarting\")\n                return \"restart\"\n            continue\n        except RuntimeError as e:\n            # Handle executor shutdown gracefully - this is expected during shutdown\n            if \"cannot schedule new futures after shutdown\" in str(e):\n                # Executor shut down - exit gracefully without logging in pytest\n                if not IN_PYTEST:\n                    logger.debug(f\"Worker {worker_id} detected executor shutdown, exiting\")\n                break\n            # Other RuntimeError - log and continue\n            logger.error(f\"Worker {worker_id} runtime error: {e}\")\n            await asyncio.sleep(0.1)\n            continue\n        except Exception as e:\n            # Handle expected Empty exception from queue timeout\n            import queue\n            if isinstance(e, queue.Empty):\n                # Queue is empty, normal behavior - just continue\n                continue\n\n            # Unexpected exception - log as critical\n            logger.critical(f\"CRITICAL: Worker {worker_id} failed to get queue item: {type(e).__name__}: {e}\")\n\n            # Log queue health for debugging\n            try:\n                queue_size = q.qsize()\n                is_empty = q.empty()\n                logger.critical(f\"CRITICAL: Worker {worker_id} queue health - size: {queue_size}, empty: {is_empty}\")\n            except Exception as health_e:\n                logger.critical(f\"CRITICAL: Worker {worker_id} queue health check failed: {health_e}\")\n\n            await asyncio.sleep(0.1)\n            continue\n\n        # UUID already claimed above immediately after getting from queue\n        # to prevent race condition with wait_for_all_checks()\n\n        fetch_start_time = round(time.time())\n\n        try:\n            if uuid in list(datastore.data['watching'].keys()) and datastore.data['watching'][uuid].get('url'):\n                changed_detected = False\n                contents = b''\n                process_changedetection_results = True\n                update_obj = {}\n\n                # Clear last errors\n                datastore.data['watching'][uuid]['browser_steps_last_error_step'] = None\n                datastore.data['watching'][uuid]['last_checked'] = fetch_start_time\n\n                watch = datastore.data['watching'].get(uuid)\n\n                logger.info(f\"Worker {worker_id} processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}\")\n\n                try:\n                    # Retrieve signal by name to ensure thread-safe access across worker threads\n                    watch_check_update = signal('watch_check_update')\n                    watch_check_update.send(watch_uuid=uuid)\n\n                    # Processor is what we are using for detecting the \"Change\"\n                    processor = watch.get('processor', 'text_json_diff')\n\n                    # Init a new 'difference_detection_processor'\n                    # Use get_processor_module() to support both built-in and plugin processors\n                    from changedetectionio.processors import get_processor_module\n                    processor_module = get_processor_module(processor)\n\n                    if not processor_module:\n                        error_msg = f\"Processor module '{processor}' not found.\"\n                        logger.error(error_msg)\n                        raise ModuleNotFoundError(error_msg)\n\n                    update_handler = processor_module.perform_site_check(datastore=datastore,\n                                                                         watch_uuid=uuid)\n\n                    # Allow plugins to modify/wrap the update_handler\n                    update_handler = apply_update_handler_alter(update_handler, watch, datastore)\n\n                    update_signal = signal('watch_small_status_comment')\n                    update_signal.send(watch_uuid=uuid, status=\"Fetching page..\")\n\n                    # All fetchers are now async, so call directly\n                    await update_handler.call_browser()\n\n                    # Run change detection in executor to avoid blocking event loop\n                    # This includes CPU-intensive operations like HTML parsing (lxml/inscriptis)\n                    # which can take 2-10ms and cause GIL contention across workers\n                    loop = asyncio.get_event_loop()\n                    changed_detected, update_obj, contents = await loop.run_in_executor(\n                        executor,\n                        lambda: update_handler.run_changedetection(watch=watch)\n                    )\n\n                except PermissionError as e:\n                    logger.critical(f\"File permission error updating file, watch: {uuid}\")\n                    logger.critical(str(e))\n                    process_changedetection_results = False\n\n                except ProcessorException as e:\n                    if e.screenshot:\n                        watch.save_screenshot(screenshot=e.screenshot)\n                        e.screenshot = None  # Free memory immediately\n                    if e.xpath_data:\n                        watch.save_xpath_data(data=e.xpath_data)\n                        e.xpath_data = None  # Free memory immediately\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': e.message})\n                    process_changedetection_results = False\n\n                except content_fetchers_exceptions.ReplyWithContentButNoText as e:\n                    extra_help = \"\"\n                    if e.has_filters:\n                        has_img = html_tools.include_filters(include_filters='img',\n                                                             html_content=e.html_content)\n                        if has_img:\n                            extra_help = \", it's possible that the filters you have give an empty result or contain only an image.\"\n                        else:\n                            extra_help = \", it's possible that the filters were found, but contained no usable text.\"\n\n                    datastore.update_watch(uuid=uuid, update_obj={\n                        'last_error': f\"Got HTML content but no text found (With {e.status_code} reply code){extra_help}\"\n                    })\n\n                    if e.screenshot:\n                        watch.save_screenshot(screenshot=e.screenshot, as_error=True)\n                        e.screenshot = None  # Free memory immediately\n\n                    if e.xpath_data:\n                        watch.save_xpath_data(data=e.xpath_data)\n                        e.xpath_data = None  # Free memory immediately\n                        \n                    process_changedetection_results = False\n\n                except content_fetchers_exceptions.Non200ErrorCodeReceived as e:\n                    if e.status_code == 403:\n                        err_text = \"Error - 403 (Access denied) received\"\n                    elif e.status_code == 404:\n                        err_text = \"Error - 404 (Page not found) received\"\n                    elif e.status_code == 407:\n                        err_text = \"Error - 407 (Proxy authentication required) received, did you need a username and password for the proxy?\"\n                    elif e.status_code == 500:\n                        err_text = \"Error - 500 (Internal server error) received from the web site\"\n                    else:\n                        extra = ' (Access denied or blocked)' if str(e.status_code).startswith('4') else ''\n                        err_text = f\"Error - Request returned a HTTP error code {e.status_code}{extra}\"\n\n                    if e.screenshot:\n                        watch.save_screenshot(screenshot=e.screenshot, as_error=True)\n                        e.screenshot = None  # Free memory immediately\n                    if e.xpath_data:\n                        watch.save_xpath_data(data=e.xpath_data, as_error=True)\n                        e.xpath_data = None  # Free memory immediately\n                    if e.page_text:\n                        watch.save_error_text(contents=e.page_text)\n\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})\n                    process_changedetection_results = False\n\n                except FilterNotFoundInResponse as e:\n                    if not datastore.data['watching'].get(uuid):\n                        continue\n                    logger.debug(f\"Received FilterNotFoundInResponse exception for {uuid}\")\n\n                    err_text = \"Warning, no filters were found, no change detection ran - Did the page change layout? update your Visual Filter if necessary.\"\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})\n\n                    # Filter wasnt found, but we should still update the visual selector so that they can have a chance to set it up again\n                    if e.screenshot:\n                        watch.save_screenshot(screenshot=e.screenshot)\n                        e.screenshot = None  # Free memory immediately\n\n                    if e.xpath_data:\n                        watch.save_xpath_data(data=e.xpath_data)\n                        e.xpath_data = None  # Free memory immediately\n\n                    # Only when enabled, send the notification\n                    if watch.get('filter_failure_notification_send', False):\n                        c = watch.get('consecutive_filter_failures', 0)\n                        c += 1\n                        # Send notification if we reached the threshold?\n                        threshold = datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)\n                        logger.debug(f\"FilterNotFoundInResponse - Filter for {uuid} not found, consecutive_filter_failures: {c} of threshold {threshold}\")\n                        if c >= threshold:\n                            if not watch.get('notification_muted'):\n                                logger.debug(f\"FilterNotFoundInResponse - Sending filter failed notification for {uuid}\")\n                                await send_filter_failure_notification(uuid, notification_q, datastore)\n                            c = 0\n                            logger.debug(f\"FilterNotFoundInResponse - Reset filter failure count back to zero\")\n                        else:\n                            logger.debug(f\"FilterNotFoundInResponse - {c} of threshold {threshold}..\")\n\n                        datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c})\n                    else:\n                        logger.trace(f\"FilterNotFoundInResponse - {uuid} - filter_failure_notification_send not enabled, skipping\")\n\n                    process_changedetection_results = False\n\n                except content_fetchers_exceptions.checksumFromPreviousCheckWasTheSame as e:\n                    # Yes fine, so nothing todo, don't continue to process.\n                    process_changedetection_results = False\n                    changed_detected = False\n                    logger.debug(f'[{uuid}] - checksumFromPreviousCheckWasTheSame - Checksum from previous check was the same, nothing todo here.')\n                    # Reset the edited flag since we successfully completed the check\n                    watch.reset_watch_edited_flag()\n                    \n                except content_fetchers_exceptions.BrowserConnectError as e:\n                    datastore.update_watch(uuid=uuid,\n                                         update_obj={'last_error': e.msg})\n                    process_changedetection_results = False\n                    \n                except content_fetchers_exceptions.BrowserFetchTimedOut as e:\n                    datastore.update_watch(uuid=uuid,\n                                         update_obj={'last_error': e.msg})\n                    process_changedetection_results = False\n                    \n                except content_fetchers_exceptions.BrowserStepsStepException as e:\n                    if not datastore.data['watching'].get(uuid):\n                        continue\n\n                    error_step = e.step_n + 1\n                    from playwright._impl._errors import TimeoutError, Error\n\n                    # Generally enough info for TimeoutError (couldnt locate the element after default seconds)\n                    err_text = f\"Browser step at position {error_step} could not run, check the watch, add a delay if necessary, view Browser Steps to see screenshot at that step.\"\n\n                    if e.original_e.name == \"TimeoutError\":\n                        # Just the first line is enough, the rest is the stack trace\n                        err_text += \" Could not find the target.\"\n                    else:\n                        # Other Error, more info is good.\n                        err_text += \" \" + str(e.original_e).splitlines()[0]\n\n                    logger.debug(f\"BrowserSteps exception at step {error_step} {str(e.original_e)}\")\n\n                    datastore.update_watch(uuid=uuid,\n                                         update_obj={'last_error': err_text,\n                                                   'browser_steps_last_error_step': error_step})\n\n                    if watch.get('filter_failure_notification_send', False):\n                        c = watch.get('consecutive_filter_failures', 0)\n                        c += 1\n                        # Send notification if we reached the threshold?\n                        threshold = datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)\n                        logger.error(f\"Step for {uuid} not found, consecutive_filter_failures: {c}\")\n                        if threshold > 0 and c >= threshold:\n                            if not watch.get('notification_muted'):\n                                await send_step_failure_notification(watch_uuid=uuid, step_n=e.step_n, notification_q=notification_q, datastore=datastore)\n                            c = 0\n\n                        datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c})\n\n                    process_changedetection_results = False\n\n                except content_fetchers_exceptions.EmptyReply as e:\n                    # Some kind of custom to-str handler in the exception handler that does this?\n                    err_text = \"EmptyReply - try increasing 'Wait seconds before extracting text', Status Code {}\".format(e.status_code)\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,\n                                                                'last_check_status': e.status_code})\n                    process_changedetection_results = False\n                    \n                except content_fetchers_exceptions.ScreenshotUnavailable as e:\n                    err_text = \"Screenshot unavailable, page did not render fully in the expected time or page was too long - try increasing 'Wait seconds before extracting text'\"\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,\n                                                                'last_check_status': e.status_code})\n                    process_changedetection_results = False\n                    \n                except content_fetchers_exceptions.JSActionExceptions as e:\n                    err_text = \"Error running JS Actions - Page request - \"+e.message\n                    if e.screenshot:\n                        watch.save_screenshot(screenshot=e.screenshot, as_error=True)\n                        e.screenshot = None  # Free memory immediately\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,\n                                                                'last_check_status': e.status_code})\n                    process_changedetection_results = False\n                    \n                except content_fetchers_exceptions.PageUnloadable as e:\n                    err_text = \"Page request from server didnt respond correctly\"\n                    if e.message:\n                        err_text = \"{} - {}\".format(err_text, e.message)\n\n                    if e.screenshot:\n                        watch.save_screenshot(screenshot=e.screenshot, as_error=True)\n                        e.screenshot = None  # Free memory immediately\n\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text,\n                                                                'last_check_status': e.status_code,\n                                                                'has_ldjson_price_data': None})\n                    process_changedetection_results = False\n                    \n                except content_fetchers_exceptions.BrowserStepsInUnsupportedFetcher as e:\n                    err_text = \"This watch has Browser Steps configured and so it cannot run with the 'Basic fast Plaintext/HTTP Client', either remove the Browser Steps or select a Chrome fetcher.\"\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})\n                    process_changedetection_results = False\n                    logger.error(f\"Exception (BrowserStepsInUnsupportedFetcher) reached processing watch UUID: {uuid}\")\n\n                except Exception as e:\n                    import traceback\n                    logger.error(f\"Worker {worker_id} exception processing watch UUID: {uuid}\")\n                    logger.exception(f\"Worker {worker_id} full exception details:\")\n                    datastore.update_watch(uuid=uuid, update_obj={'last_error': \"Exception: \" + str(e)})\n                    process_changedetection_results = False\n\n                else:\n                    if not datastore.data['watching'].get(uuid):\n                        continue\n\n                    update_obj['content-type'] = str(update_handler.fetcher.get_all_headers().get('content-type', '') or \"\").lower()\n\n                    if not watch.get('ignore_status_codes'):\n                        update_obj['consecutive_filter_failures'] = 0\n\n                    update_obj['last_error'] = False\n                    cleanup_error_artifacts(uuid, datastore)\n\n                if not datastore.data['watching'].get(uuid):\n                    continue\n\n                logger.debug(f\"Processing watch UUID: {uuid} - xpath_data length returned {len(update_handler.xpath_data) if update_handler and update_handler.xpath_data else 'empty.'}\")\n                if update_handler and process_changedetection_results:\n                    try:\n                        # Reset the edited flag BEFORE update_watch (which calls watch.update() and would set it again)\n                        watch.reset_watch_edited_flag()\n                        datastore.update_watch(uuid=uuid, update_obj=update_obj)\n\n                        if changed_detected or not watch.history_n:\n                            if update_handler.screenshot:\n                                watch.save_screenshot(screenshot=update_handler.screenshot)\n                                # Free screenshot memory immediately after saving\n                                update_handler.screenshot = None\n                                if hasattr(update_handler, 'fetcher') and hasattr(update_handler.fetcher, 'screenshot'):\n                                    update_handler.fetcher.screenshot = None\n\n                            if update_handler.xpath_data:\n                                watch.save_xpath_data(data=update_handler.xpath_data)\n                                # Free xpath data memory\n                                update_handler.xpath_data = None\n                                if hasattr(update_handler, 'fetcher') and hasattr(update_handler.fetcher, 'xpath_data'):\n                                    update_handler.fetcher.xpath_data = None\n\n                            # Ensure unique timestamp for history\n                            if watch.newest_history_key and int(fetch_start_time) == int(watch.newest_history_key):\n                                logger.warning(f\"Timestamp {fetch_start_time} already exists, waiting 1 seconds\")\n                                fetch_start_time += 1\n                                await asyncio.sleep(1)\n\n                            watch.save_history_blob(contents=contents,\n                                                    timestamp=int(fetch_start_time),\n                                                    snapshot_id=update_obj.get('previous_md5', 'none'))\n\n                            empty_pages_are_a_change = datastore.data['settings']['application'].get('empty_pages_are_a_change', False)\n                            if update_handler.fetcher.content or (not update_handler.fetcher.content and empty_pages_are_a_change):\n                                watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=int(fetch_start_time))\n\n                            # Explicitly delete large content variables to free memory IMMEDIATELY after saving\n                            # These are no longer needed after being saved to history\n                            del contents\n\n                            # Send notifications on second+ check\n                            if watch.history_n >= 2:\n                                logger.info(f\"Change detected in UUID {uuid} - {watch['url']}\")\n                                if not watch.get('notification_muted'):\n                                    await send_content_changed_notification(uuid, notification_q, datastore)\n\n                    except Exception as e:\n\n                        logger.critical(f\"Worker {worker_id} exception in process_changedetection_results\")\n                        logger.exception(f\"Worker {worker_id} full exception details:\")\n                        datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})\n\n\n                # Always record attempt count\n                count = watch.get('check_count', 0) + 1\n\n                final_updates = {'fetch_time': round(time.time() - fetch_start_time, 3),\n                                                                  'check_count': count,\n                                                                  }\n                # Record server header\n                try:\n                    server_header = str(update_handler.fetcher.get_all_headers().get('server', '') or \"\").strip().lower()[:255]\n                    if server_header:\n                        final_updates['remote_server_reply'] = server_header\n                except Exception as e:\n                    server_header = None\n                    pass\n\n                if update_handler: # Could be none or empty if the processor was not found\n                    # Always record page title (used in notifications, and can change even when the content is the same)\n                    if update_obj.get('content-type') and 'html' in update_obj.get('content-type'):\n                        try:\n                            page_title = html_tools.extract_title(data=update_handler.fetcher.content)\n                            if page_title:\n                                page_title = page_title.strip()[:2000]\n                                logger.debug(f\"UUID: {uuid} Page <title> is '{page_title}'\")\n                                final_updates['page_title'] = page_title\n                        except Exception as e:\n                            logger.exception(f\"Worker {worker_id} full exception details:\")\n                            logger.warning(f\"UUID: {uuid} Exception when extracting <title> - {str(e)}\")\n\n                    # Store favicon if necessary\n                    if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):\n                        watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),\n                                           favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')\n                                           )\n\n                    datastore.update_watch(uuid=uuid, update_obj=final_updates)\n\n                    # NOW clear fetcher content - after all processing is complete\n                    # This is the last point where we need the fetcher data\n                    if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:\n                        update_handler.fetcher.clear_content()\n\n                    # Explicitly delete update_handler to free all references\n                    if update_handler:\n                        del update_handler\n                        update_handler = None\n\n                # Force garbage collection\n                import gc\n                gc.collect()\n\n        except Exception as e:\n            # Store the processing exception for plugin finalization hook\n            processing_exception = e\n\n            logger.error(f\"Worker {worker_id} unexpected error processing {uuid}: {e}\")\n            logger.exception(f\"Worker {worker_id} full exception details:\")\n\n            # Also update the watch with error information\n            if datastore and uuid in datastore.data['watching']:\n                datastore.update_watch(uuid=uuid, update_obj={'last_error': f\"Worker error: {str(e)}\"})\n        \n        finally:\n            # Always cleanup - this runs whether there was an exception or not\n            if uuid:\n                # Capture references for plugin finalize hook BEFORE cleanup\n                # (cleanup may delete these variables, but plugins need the original references)\n                finalize_handler = update_handler  # Capture now, before cleanup deletes it\n                finalize_watch = watch              # Capture now, before any modifications\n\n                # Call quit() as backup (Puppeteer/Playwright have internal cleanup, but this acts as safety net)\n                try:\n                    if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:\n                        await update_handler.fetcher.quit(watch=watch)\n                except Exception as e:\n                    logger.error(f\"Exception while cleaning/quit after calling browser: {e}\")\n                    logger.exception(f\"Worker {worker_id} full exception details:\")\n\n                try:\n\n                    # Clean up all memory references BEFORE garbage collection\n                    if update_handler:\n                        if hasattr(update_handler, 'fetcher') and update_handler.fetcher:\n                            update_handler.fetcher.clear_content()\n                        if hasattr(update_handler, 'content_processor'):\n                            update_handler.content_processor = None\n                        del update_handler\n                        update_handler = None\n\n                    # Clear large content variables\n                    if 'contents' in locals():\n                        del contents\n\n                    # Force garbage collection after all references are cleared\n                    import gc\n                    gc.collect()\n\n                    logger.debug(f\"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s\")\n                except Exception as cleanup_error:\n                    logger.error(f\"Worker {worker_id} error during cleanup: {cleanup_error}\")\n                    logger.exception(f\"Worker {worker_id} full exception details:\")\n\n                # Call plugin finalization hook after all cleanup is done\n                # Use captured references from before cleanup\n                try:\n                    apply_update_finalize(\n                        update_handler=finalize_handler,\n                        watch=finalize_watch,\n                        datastore=datastore,\n                        processing_exception=processing_exception\n                    )\n                except Exception as finalize_error:\n                    logger.error(f\"Worker {worker_id} error in finalize hook: {finalize_error}\")\n                    logger.exception(f\"Worker {worker_id} full exception details:\")\n                finally:\n                    # Clean up captured references to allow immediate garbage collection\n                    del finalize_handler\n                    del finalize_watch\n\n                # Release UUID from processing AFTER all cleanup and hooks complete (thread-safe)\n                # This ensures wait_for_all_checks() waits for finalize hooks to complete\n                try:\n                    worker_pool.release_uuid_from_processing(uuid, worker_id=worker_id)\n                except Exception as release_error:\n                    logger.error(f\"Worker {worker_id} error releasing UUID: {release_error}\")\n                    logger.exception(f\"Worker {worker_id} full exception details:\")\n                finally:\n                    # Send completion signal - retrieve by name to ensure thread-safe access\n                    if watch:\n                        watch_check_update = signal('watch_check_update')\n                        watch_check_update.send(watch_uuid=watch['uuid'])\n\n            del (uuid)\n\n            # Brief pause before continuing to avoid tight error loops (only on error)\n            if 'e' in locals():\n                await asyncio.sleep(1.0)\n            else:\n                # Small yield for normal completion\n                await asyncio.sleep(0.01)\n\n            # Job completed - increment counter and check restart conditions\n            jobs_processed += 1\n            runtime = time.time() - start_time\n\n            # Check if we should restart (only when idle, between jobs)\n            should_restart_jobs = jobs_processed >= max_jobs\n            should_restart_time = runtime >= max_runtime_seconds\n\n            if should_restart_jobs or should_restart_time:\n                reason = f\"{jobs_processed} jobs\" if should_restart_jobs else f\"{runtime:.0f}s runtime\"\n                logger.info(f\"Worker {worker_id} restarting after {reason} ({jobs_processed} jobs, {runtime:.0f}s runtime)\")\n                return \"restart\"\n\n        # Check if we should exit\n        if app.config.exit.is_set():\n            break\n\n    # Check if we're in pytest environment - if so, be more gentle with logging\n    import sys\n    in_pytest = \"pytest\" in sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ\n\n    if not in_pytest:\n        logger.info(f\"Worker {worker_id} shutting down\")\n\n    return \"shutdown\"\n\n\ndef cleanup_error_artifacts(uuid, datastore):\n    \"\"\"Helper function to clean up error artifacts\"\"\"\n    cleanup_files = [\"last-error-screenshot.png\", \"last-error.txt\"]\n    for f in cleanup_files:\n        full_path = os.path.join(datastore.datastore_path, uuid, f)\n        if os.path.isfile(full_path):\n            os.unlink(full_path)\n\n\n\nasync def send_content_changed_notification(watch_uuid, notification_q, datastore):\n    \"\"\"Helper function to queue notifications using the new notification service\"\"\"\n    try:\n        from changedetectionio.notification_service import create_notification_service\n        \n        # Create notification service instance\n        notification_service = create_notification_service(datastore, notification_q)\n        \n        notification_service.send_content_changed_notification(watch_uuid)\n    except Exception as e:\n        logger.error(f\"Error sending notification for {watch_uuid}: {e}\")\n\n\nasync def send_filter_failure_notification(watch_uuid, notification_q, datastore):\n    \"\"\"Helper function to send filter failure notifications using the new notification service\"\"\"\n    try:\n        from changedetectionio.notification_service import create_notification_service\n        \n        # Create notification service instance\n        notification_service = create_notification_service(datastore, notification_q)\n        \n        notification_service.send_filter_failure_notification(watch_uuid)\n    except Exception as e:\n        logger.error(f\"Error sending filter failure notification for {watch_uuid}: {e}\")\n\n\nasync def send_step_failure_notification(watch_uuid, step_n, notification_q, datastore):\n    \"\"\"Helper function to send step failure notifications using the new notification service\"\"\"\n    try:\n        from changedetectionio.notification_service import create_notification_service\n        \n        # Create notification service instance\n        notification_service = create_notification_service(datastore, notification_q)\n        \n        notification_service.send_step_failure_notification(watch_uuid, step_n)\n    except Exception as e:\n        logger.error(f\"Error sending step failure notification for {watch_uuid}: {e}\")"
  },
  {
    "path": "changedetectionio/worker_pool.py",
    "content": "\"\"\"\nWorker management module for changedetection.io\n\nHandles asynchronous workers for dynamic worker scaling.\nEach worker runs in its own thread with its own event loop for isolation.\n\"\"\"\n\nimport asyncio\nimport os\nimport threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom loguru import logger\n\n# Global worker state - each worker has its own thread and event loop\nworker_threads = []  # List of WorkerThread objects\n\n# Track currently processing UUIDs for async workers - maps {uuid: worker_id}\ncurrently_processing_uuids = {}\n_uuid_processing_lock = threading.Lock()  # Protects currently_processing_uuids\n\n# Configuration - async workers only\nUSE_ASYNC_WORKERS = True\n\n# Custom ThreadPoolExecutor for queue operations with named threads\n# Scale executor threads to match FETCH_WORKERS (no minimum, no maximum)\n# Thread naming: \"QueueGetter-N\" for easy debugging in thread dumps/traces\n# With FETCH_WORKERS=10: 10 workers + 10 executor threads = 20 threads total\n# With FETCH_WORKERS=500: 500 workers + 500 executor threads = 1000 threads total (acceptable on modern systems)\n_max_executor_workers = int(os.getenv(\"FETCH_WORKERS\", \"10\"))\nqueue_executor = ThreadPoolExecutor(\n    max_workers=_max_executor_workers,\n    thread_name_prefix=\"QueueGetter-\"  # Shows in thread dumps as \"QueueGetter-0\", \"QueueGetter-1\", etc.\n)\n\n\nclass WorkerThread:\n    \"\"\"Container for a worker thread with its own event loop\"\"\"\n    def __init__(self, worker_id, update_q, notification_q, app, datastore):\n        self.worker_id = worker_id\n        self.update_q = update_q\n        self.notification_q = notification_q\n        self.app = app\n        self.datastore = datastore\n        self.thread = None\n        self.loop = None\n        self.running = False\n\n    def run(self):\n        \"\"\"Run the worker in its own event loop\"\"\"\n        try:\n            # Create a new event loop for this thread\n            self.loop = asyncio.new_event_loop()\n            asyncio.set_event_loop(self.loop)\n            self.running = True\n\n            # Run the worker coroutine\n            self.loop.run_until_complete(\n                start_single_async_worker(\n                    self.worker_id,\n                    self.update_q,\n                    self.notification_q,\n                    self.app,\n                    self.datastore,\n                    queue_executor\n                )\n            )\n        except asyncio.CancelledError:\n            # Normal shutdown - worker was cancelled\n            import os\n            in_pytest = \"pytest\" in os.sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ\n            if not in_pytest:\n                logger.info(f\"Worker {self.worker_id} shutting down gracefully\")\n        except RuntimeError as e:\n            # Ignore expected shutdown errors\n            if \"Event loop stopped\" not in str(e) and \"Event loop is closed\" not in str(e):\n                logger.error(f\"Worker {self.worker_id} runtime error: {e}\")\n        except Exception as e:\n            logger.error(f\"Worker {self.worker_id} thread error: {e}\")\n        finally:\n            # Clean up\n            if self.loop and not self.loop.is_closed():\n                self.loop.close()\n            self.running = False\n            self.loop = None\n\n    def start(self):\n        \"\"\"Start the worker thread with descriptive name for debugging\"\"\"\n        self.thread = threading.Thread(\n            target=self.run,\n            daemon=True,\n            name=f\"PageFetchAsyncUpdateWorker-{self.worker_id}\"  # Shows in thread dumps with worker ID\n        )\n        self.thread.start()\n\n    def stop(self):\n        \"\"\"Stop the worker thread brutally - no waiting\"\"\"\n        # Try to stop the event loop if it exists\n        if self.loop and self.running:\n            try:\n                # Signal the loop to stop\n                self.loop.call_soon_threadsafe(self.loop.stop)\n            except RuntimeError:\n                pass\n\n        # Don't wait - thread is daemon and will die when needed\n\n\ndef start_async_workers(n_workers, update_q, notification_q, app, datastore):\n    \"\"\"Start async workers, each with its own thread and event loop for isolation\"\"\"\n    global worker_threads, currently_processing_uuids\n\n    # Clear any stale state\n    currently_processing_uuids.clear()\n\n    # Start each worker in its own thread with its own event loop\n    logger.info(f\"Starting {n_workers} async workers (isolated threads)\")\n    for i in range(n_workers):\n        try:\n            worker = WorkerThread(i, update_q, notification_q, app, datastore)\n            worker.start()\n            worker_threads.append(worker)\n            # No sleep needed - threads start independently and asynchronously\n        except Exception as e:\n            logger.error(f\"Failed to start async worker {i}: {e}\")\n            continue\n\n\nasync def start_single_async_worker(worker_id, update_q, notification_q, app, datastore, executor=None):\n    \"\"\"Start a single async worker with auto-restart capability\"\"\"\n    from changedetectionio.worker import async_update_worker\n\n    # Check if we're in pytest environment - if so, be more gentle with logging\n    import os\n    in_pytest = \"pytest\" in os.sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ\n\n    while not app.config.exit.is_set():\n        try:\n            result = await async_update_worker(worker_id, update_q, notification_q, app, datastore, executor)\n\n            if result == \"restart\":\n                # Worker requested restart - immediately loop back and restart\n                if not in_pytest:\n                    logger.debug(f\"Async worker {worker_id} restarting\")\n                continue\n            else:\n                # Worker exited cleanly (shutdown)\n                if not in_pytest:\n                    logger.info(f\"Async worker {worker_id} exited cleanly\")\n                break\n\n        except asyncio.CancelledError:\n            # Task was cancelled (normal shutdown)\n            if not in_pytest:\n                logger.info(f\"Async worker {worker_id} cancelled\")\n            break\n        except Exception as e:\n            logger.error(f\"Async worker {worker_id} crashed: {e}\")\n            if not in_pytest:\n                logger.info(f\"Restarting async worker {worker_id} in 5 seconds...\")\n            await asyncio.sleep(5)\n\n    if not in_pytest:\n        logger.info(f\"Async worker {worker_id} shutdown complete\")\n\n\ndef start_workers(n_workers, update_q, notification_q, app, datastore):\n    \"\"\"Start async workers - sync workers are deprecated\"\"\"\n    start_async_workers(n_workers, update_q, notification_q, app, datastore)\n\n\ndef add_worker(update_q, notification_q, app, datastore):\n    \"\"\"Add a new async worker (for dynamic scaling)\"\"\"\n    global worker_threads\n\n    # Reuse lowest available ID to prevent unbounded growth over time\n    used_ids = {w.worker_id for w in worker_threads}\n    worker_id = 0\n    while worker_id in used_ids:\n        worker_id += 1\n    logger.info(f\"Adding async worker {worker_id}\")\n\n    try:\n        worker = WorkerThread(worker_id, update_q, notification_q, app, datastore)\n        worker.start()\n        worker_threads.append(worker)\n        return True\n    except Exception as e:\n        logger.error(f\"Failed to add worker {worker_id}: {e}\")\n        return False\n\n\ndef remove_worker():\n    \"\"\"Remove an async worker (for dynamic scaling)\"\"\"\n    global worker_threads\n\n    if not worker_threads:\n        return False\n\n    # Stop the last worker\n    worker = worker_threads.pop()\n    worker.stop()\n    logger.info(f\"Removed async worker, {len(worker_threads)} workers remaining\")\n    return True\n\n\ndef get_worker_count():\n    \"\"\"Get current number of async workers\"\"\"\n    return len(worker_threads)\n\n\ndef get_running_uuids():\n    \"\"\"Get list of UUIDs currently being processed by async workers\"\"\"\n    with _uuid_processing_lock:\n        return list(currently_processing_uuids.keys())\n\n\ndef claim_uuid_for_processing(uuid, worker_id):\n    \"\"\"\n    Atomically check if UUID is available and claim it for processing.\n\n    This is thread-safe and prevents race conditions where multiple workers\n    try to process the same UUID simultaneously.\n\n    Args:\n        uuid: The watch UUID to claim\n        worker_id: The ID of the worker claiming this UUID\n\n    Returns:\n        True if successfully claimed (UUID was not being processed)\n        False if already being processed by another worker\n    \"\"\"\n    with _uuid_processing_lock:\n        if uuid in currently_processing_uuids:\n            # Already being processed by another worker\n            return False\n        # Claim it atomically\n        currently_processing_uuids[uuid] = worker_id\n        logger.debug(f\"Worker {worker_id} claimed UUID: {uuid}\")\n        return True\n\n\ndef release_uuid_from_processing(uuid, worker_id):\n    \"\"\"\n    Release a UUID from processing (thread-safe).\n\n    Args:\n        uuid: The watch UUID to release\n        worker_id: The ID of the worker releasing this UUID\n    \"\"\"\n    with _uuid_processing_lock:\n        # Only remove if this worker owns it (defensive)\n        if currently_processing_uuids.get(uuid) == worker_id:\n            currently_processing_uuids.pop(uuid, None)\n            logger.debug(f\"Worker {worker_id} released UUID: {uuid}\")\n        else:\n            logger.warning(f\"Worker {worker_id} tried to release UUID {uuid} but doesn't own it (owned by {currently_processing_uuids.get(uuid, 'nobody')})\")\n\n\ndef set_uuid_processing(uuid, worker_id=None, processing=True):\n    \"\"\"\n    Mark a UUID as being processed or completed by a specific worker.\n\n    DEPRECATED: Use claim_uuid_for_processing() and release_uuid_from_processing() instead.\n    This function is kept for backward compatibility but doesn't provide atomic check-and-set.\n    \"\"\"\n    if processing:\n        with _uuid_processing_lock:\n            currently_processing_uuids[uuid] = worker_id\n            logger.debug(f\"Worker {worker_id} started processing UUID: {uuid}\")\n    else:\n        release_uuid_from_processing(uuid, worker_id)\n\n\ndef is_watch_running(watch_uuid):\n    \"\"\"Check if a specific watch is currently being processed by any worker\"\"\"\n    with _uuid_processing_lock:\n        return watch_uuid in currently_processing_uuids\n\n\ndef is_watch_running_by_another_worker(watch_uuid, current_worker_id):\n    \"\"\"Check if a specific watch is currently being processed by a different worker\"\"\"\n    with _uuid_processing_lock:\n        if watch_uuid not in currently_processing_uuids:\n            return False\n        processing_worker_id = currently_processing_uuids[watch_uuid]\n        return processing_worker_id != current_worker_id\n\n\ndef queue_item_async_safe(update_q, item, silent=False):\n    \"\"\"Bulletproof queue operation with comprehensive error handling\"\"\"\n    item_uuid = 'unknown'\n\n    try:\n        # Safely extract UUID for logging\n        if hasattr(item, 'item') and isinstance(item.item, dict):\n            item_uuid = item.item.get('uuid', 'unknown')\n    except Exception as uuid_e:\n        logger.critical(f\"CRITICAL: Failed to extract UUID from queue item: {uuid_e}\")\n\n    # Validate inputs\n    if not update_q:\n        logger.critical(f\"CRITICAL: Queue is None/invalid for item {item_uuid}\")\n        return False\n\n    if not item:\n        logger.critical(f\"CRITICAL: Item is None/invalid\")\n        return False\n\n    # Attempt queue operation with multiple fallbacks\n    try:\n        # Primary: Use sync interface (thread-safe)\n        success = update_q.put(item, block=True, timeout=5.0)\n        if success is False:  # Explicit False return means failure\n            logger.critical(f\"CRITICAL: Queue.put() returned False for item {item_uuid}\")\n            return False\n\n        if not silent:\n            logger.trace(f\"Successfully queued item: {item_uuid}\")\n        return True\n        \n    except Exception as e:\n        logger.critical(f\"CRITICAL: Exception during queue operation for item {item_uuid}: {type(e).__name__}: {e}\")\n        \n        # Secondary: Attempt queue health check\n        try:\n            queue_size = update_q.qsize()\n            is_empty = update_q.empty()\n            logger.critical(f\"CRITICAL: Queue health - size: {queue_size}, empty: {is_empty}\")\n        except Exception as health_e:\n            logger.critical(f\"CRITICAL: Queue health check failed: {health_e}\")\n        \n        # Log queue type for debugging\n        try:\n            logger.critical(f\"CRITICAL: Queue type: {type(update_q)}, has sync_q: {hasattr(update_q, 'sync_q')}\")\n        except Exception:\n            logger.critical(f\"CRITICAL: Cannot determine queue type\")\n        \n        return False\n\n\ndef shutdown_workers():\n    \"\"\"Shutdown all async workers brutally - no delays, no waiting\"\"\"\n    global worker_threads, queue_executor\n\n    # Check if we're in pytest environment - if so, be more gentle with logging\n    import os\n    in_pytest = \"pytest\" in os.sys.modules or \"PYTEST_CURRENT_TEST\" in os.environ\n\n    if not in_pytest:\n        logger.info(\"Brutal shutdown of async workers initiated...\")\n\n    # Stop all worker event loops\n    for worker in worker_threads:\n        worker.stop()\n\n    # Clear immediately - threads are daemon and will die\n    worker_threads.clear()\n\n    # Shutdown the queue executor to prevent \"cannot schedule new futures after shutdown\" errors\n    # This must happen AFTER workers are stopped to avoid race conditions\n    if queue_executor:\n        try:\n            queue_executor.shutdown(wait=False)\n            if not in_pytest:\n                logger.debug(\"Queue executor shut down\")\n        except Exception as e:\n            if not in_pytest:\n                logger.warning(f\"Error shutting down queue executor: {e}\")\n\n    if not in_pytest:\n        logger.info(\"Async workers brutal shutdown complete\")\n\n\n\n\ndef adjust_async_worker_count(new_count, update_q=None, notification_q=None, app=None, datastore=None):\n    \"\"\"\n    Dynamically adjust the number of async workers.\n\n    Args:\n        new_count: Target number of workers\n        update_q, notification_q, app, datastore: Required for adding new workers\n\n    Returns:\n        dict: Status of the adjustment operation\n    \"\"\"\n    global worker_threads\n\n    current_count = get_worker_count()\n\n    if new_count == current_count:\n        return {\n            'status': 'no_change',\n            'message': f'Worker count already at {current_count}',\n            'current_count': current_count\n        }\n\n    if new_count > current_count:\n        # Add workers\n        workers_to_add = new_count - current_count\n        logger.info(f\"Adding {workers_to_add} async workers (from {current_count} to {new_count})\")\n\n        if not all([update_q, notification_q, app, datastore]):\n            return {\n                'status': 'error',\n                'message': 'Missing required parameters to add workers',\n                'current_count': current_count\n            }\n\n        for i in range(workers_to_add):\n            add_worker(update_q, notification_q, app, datastore)\n\n        return {\n            'status': 'success',\n            'message': f'Added {workers_to_add} workers',\n            'previous_count': current_count,\n            'current_count': len(worker_threads)\n        }\n\n    else:\n        # Remove workers\n        workers_to_remove = current_count - new_count\n        logger.info(f\"Removing {workers_to_remove} async workers (from {current_count} to {new_count})\")\n\n        removed_count = 0\n        for _ in range(workers_to_remove):\n            if remove_worker():\n                removed_count += 1\n\n        return {\n            'status': 'success',\n            'message': f'Removed {removed_count} workers',\n            'previous_count': current_count,\n            'current_count': current_count - removed_count\n        }\n\n\ndef get_worker_status():\n    \"\"\"Get status information about async workers\"\"\"\n    return {\n        'worker_type': 'async',\n        'worker_count': get_worker_count(),\n        'running_uuids': get_running_uuids(),\n        'active_threads': sum(1 for w in worker_threads if w.thread and w.thread.is_alive()),\n    }\n\n\ndef wait_for_all_checks(update_q, timeout=150):\n    \"\"\"\n    Wait for queue to be empty and all workers to be idle.\n\n    Args:\n        update_q: The update queue to monitor\n        timeout: Maximum wait time in seconds (default 150 = 150 iterations * 0.2-0.8s)\n\n    Returns:\n        bool: True if all checks completed, False if timeout\n    \"\"\"\n    import time\n    empty_since = None\n    attempt = 0\n    max_attempts = timeout\n\n    while attempt < max_attempts:\n        # Adaptive sleep - start fast, slow down if needed\n        if attempt < 10:\n            sleep_time = 0.2  # Very fast initial checks\n        elif attempt < 30:\n            sleep_time = 0.4  # Medium speed\n        else:\n            sleep_time = 0.8  # Slower for persistent issues\n\n        time.sleep(sleep_time)\n\n        q_length = update_q.qsize()\n        running_uuids = get_running_uuids()\n        any_workers_busy = len(running_uuids) > 0\n\n        if q_length == 0 and not any_workers_busy:\n            if empty_since is None:\n                empty_since = time.time()\n            # Brief stabilization period for async workers\n            elif time.time() - empty_since >= 0.3:\n                # Add small buffer for filesystem operations to complete\n                time.sleep(0.2)\n                logger.trace(\"wait_for_all_checks: All checks complete (queue empty, workers idle)\")\n                return True\n        else:\n            empty_since = None\n\n        attempt += 1\n\n    logger.warning(f\"wait_for_all_checks: Timeout after {timeout} attempts\")\n    return False  # Timeout\n\n\ndef check_worker_health(expected_count, update_q=None, notification_q=None, app=None, datastore=None):\n    \"\"\"\n    Check if the expected number of async workers are running and restart any missing ones.\n\n    Args:\n        expected_count: Expected number of workers\n        update_q, notification_q, app, datastore: Required for restarting workers\n\n    Returns:\n        dict: Health check results\n    \"\"\"\n    global worker_threads\n\n    current_count = get_worker_count()\n\n    # Check which workers are actually alive\n    alive_count = sum(1 for w in worker_threads if w.thread and w.thread.is_alive())\n\n    if alive_count == expected_count:\n        return {\n            'status': 'healthy',\n            'expected_count': expected_count,\n            'actual_count': alive_count,\n            'message': f'All {expected_count} async workers running'\n        }\n\n    # Find dead workers\n    dead_workers = []\n    for i, worker in enumerate(worker_threads[:]):\n        if not worker.thread or not worker.thread.is_alive():\n            dead_workers.append(i)\n            logger.warning(f\"Async worker {worker.worker_id} thread is dead\")\n\n    # Remove dead workers from tracking\n    for i in reversed(dead_workers):\n        if i < len(worker_threads):\n            worker_threads.pop(i)\n\n    missing_workers = expected_count - alive_count\n    restarted_count = 0\n\n    if missing_workers > 0 and all([update_q, notification_q, app, datastore]):\n        logger.info(f\"Restarting {missing_workers} crashed async workers\")\n\n        for i in range(missing_workers):\n            if add_worker(update_q, notification_q, app, datastore):\n                restarted_count += 1\n\n    return {\n        'status': 'repaired' if restarted_count > 0 else 'degraded',\n        'expected_count': expected_count,\n        'actual_count': alive_count,\n        'dead_workers': len(dead_workers),\n        'restarted_workers': restarted_count,\n        'message': f'Found {len(dead_workers)} dead workers, restarted {restarted_count}'\n    }"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n    changedetection:\n      image: ghcr.io/dgtlmoon/changedetection.io\n      container_name: changedetection\n      hostname: changedetection\n      volumes:\n        - changedetection-data:/datastore\n# Configurable proxy list support, see https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#proxy-list-support\n#        - ./proxies.json:/datastore/proxies.json\n\n  #    environment:\n  #        Default listening port, can also be changed with the -p option (not to be confused with ports: below)\n  #      - PORT=5000\n  #\n  #        Log levels are in descending order. (TRACE is the most detailed one)\n  #        Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL\n  #      - LOGGER_LEVEL=TRACE\n  #\n  #        Plugins! See https://changedetection.io/plugins for more plugins.\n  #        Install additional Python packages (processor plugins, etc.)\n  #        Example: Install the OSINT reconnaissance processor plugin\n  #      - EXTRA_PACKAGES=changedetection.io-osint-processor\n  #        Multiple packages can be installed by separating with spaces:\n  #      - EXTRA_PACKAGES=changedetection.io-osint-processor another-plugin\n  #\n  #\n  #       Uncomment below and the \"sockpuppetbrowser\" to use a real Chrome browser (It uses the \"playwright\" protocol)\n  #      - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000\n  #\n  #\n  #       Alternative WebDriver/selenium URL, do not use \"'s or 's! (old, deprecated, does not support screenshots very well)\n  #      - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub\n  #\n  #       WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy,\n  #                                webdriver_proxyAutoconfigUrl, webdriver_autodetect,\n  #                                webdriver_socksProxy, webdriver_socksUsername, webdriver_socksVersion, webdriver_socksPassword\n  #\n  #             https://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.proxy\n  #\n  #\n  #       Playwright proxy settings playwright_proxy_server, playwright_proxy_bypass, playwright_proxy_username, playwright_proxy_password\n  #\n  #             https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-option-proxy\n  #\n  #        Plain requests - proxy support example.\n  #      - HTTP_PROXY=socks5h://10.10.1.10:1080\n  #      - HTTPS_PROXY=socks5h://10.10.1.10:1080\n  #\n  #        An exclude list (useful for notification URLs above) can be specified by with\n  #      - NO_PROXY=\"localhost,192.168.0.0/24\"\n  #\n  #        Base URL of your changedetection.io install (Added to the notification alert)\n  #      - BASE_URL=https://mysite.com\n  #        Respect proxy_pass type settings, `proxy_set_header Host \"localhost\";` and `proxy_set_header X-Forwarded-Prefix /app;`\n  #        More here https://github.com/dgtlmoon/changedetection.io/wiki/Running-changedetection.io-behind-a-reverse-proxy\n  #      - USE_X_SETTINGS=1\n  #\n  #        Hides the `Referer` header so that monitored websites can't see the changedetection.io hostname.\n  #      - HIDE_REFERER=true\n  #        \n  #        Default number of parallel/concurrent fetchers\n  #      - FETCH_WORKERS=10\n  #\n  #        Absolute minimum seconds to recheck, overrides any watch minimum, change to 0 to disable\n  #      - MINIMUM_SECONDS_RECHECK_TIME=3\n  #\n  #        If you want to watch local files file:///path/to/file.txt (careful! security implications!)\n  #      - ALLOW_FILE_URI=False\n  #\n  #        For complete privacy if you don't want to use the 'check version' / telemetry service\n  #      - DISABLE_VERSION_CHECK=true\n  #\n  #        A valid timezone name to run as (for scheduling watch checking) see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones\n  #      - TZ=America/Los_Angeles\n  #\n  #        Text processing locale, en_US.UTF-8 used by default unless defined as something else here, UTF-8 should cover 99.99% of cases.\n  #      - LC_ALL=en_US.UTF-8\n  #\n  #        Maximum height of screenshots, default is 16000 px, screenshots will be clipped to this if exceeded.\n  #        RAM usage will be higher if you increase this.\n  #      - SCREENSHOT_MAX_HEIGHT=16000\n  #\n  #        HTTPS SSL Mode for webserver, unset both of these, you may need to volume mount these files also.\n  #        ./cert.pem:/app/cert.pem and ./privkey.pem:/app/privkey.pem\n  #      - SSL_CERT_FILE=cert.pem\n  #      - SSL_PRIVKEY_FILE=privkey.pem\n  #\n  #        LISTEN_HOST / \"host\", Same as -h\n  #      - LISTEN_HOST=::\n  #      - LISTEN_HOST=0.0.0.0\n\n  \n      # Comment out ports: when using behind a reverse proxy , enable networks: etc.\n      # Mac users! Use \"127.0.0.1:5050:5000\" (port 5050) so theres no conflict with Airplay etc. (https://github.com/dgtlmoon/changedetection.io/issues/3401)\n      ports:\n        - 127.0.0.1:5000:5000\n      restart: unless-stopped\n\n     # Used for fetching pages via WebDriver+Chrome where you need Javascript support.\n     # Now working on arm64 (needs testing on rPi - tested on Oracle ARM instance)\n     # replace image with seleniarm/standalone-chromium:4.0.0-20211213\n     \n     # If WEBDRIVER or PLAYWRIGHT are enabled, changedetection container depends on that\n     # and must wait before starting (substitute \"browser-chrome\" with \"playwright-chrome\" if last one is used)\n#      depends_on:\n#          browser-sockpuppet-chrome:\n#              condition: service_started\n\n\n     # Sockpuppetbrowser is basically chrome wrapped in an API for allowing fast fetching of web-pages.\n     # RECOMMENDED FOR FETCHING PAGES WITH CHROME, be sure to enable the \"PLAYWRIGHT_DRIVER_URL\" env variable in the main changedetection container\n#    browser-sockpuppet-chrome:\n#        hostname: browser-sockpuppet-chrome\n#        image: dgtlmoon/sockpuppetbrowser:latest\n#        cap_add:\n#            - SYS_ADMIN\n## SYS_ADMIN might be too much, but it can be needed on your platform https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-puppeteer-on-gitlabci\n#        restart: unless-stopped\n#        environment:\n#            - SCREEN_WIDTH=1920\n#            - SCREEN_HEIGHT=1024\n#            - SCREEN_DEPTH=16\n#            - MAX_CONCURRENT_CHROME_PROCESSES=10\n\n     # Used for fetching pages via Playwright+Chrome where you need Javascript support.\n     # Note: Works well but is deprecated, does not fetch full page screenshots (doesnt work with Visual Selector)\n     #       Does not report status codes (200, 404, 403) and other issues\n#    browser-selenium-chrome:\n#        hostname: browser-selenium-chrome\n#        image: selenium/standalone-chrome:4\n#        environment:\n#            - VNC_NO_PASSWORD=1\n#            - SCREEN_WIDTH=1920\n#            - SCREEN_HEIGHT=1080\n#            - SCREEN_DEPTH=24\n#          CHROME_OPTIONS: |\n#            --window-size=1280,1024\n#            --headless\n#            --disable-gpu\n#        volumes:\n#            # Workaround to avoid the browser crashing inside a docker container\n#            # See https://github.com/SeleniumHQ/docker-selenium#quick-start\n#            - /dev/shm:/dev/shm\n#        restart: unless-stopped\n\nvolumes:\n  changedetection-data:\n\n"
  },
  {
    "path": "docker-entrypoint.sh",
    "content": "#!/bin/bash\nset -e\n\n# Install additional packages from EXTRA_PACKAGES env var\n# Uses a marker file to avoid reinstalling on every container restart\nINSTALLED_MARKER=\"/datastore/.extra_packages_installed\"\nCURRENT_PACKAGES=\"$EXTRA_PACKAGES\"\n\nif [ -n \"$EXTRA_PACKAGES\" ]; then\n    # Check if we need to install/update packages\n    if [ ! -f \"$INSTALLED_MARKER\" ] || [ \"$(cat $INSTALLED_MARKER 2>/dev/null)\" != \"$CURRENT_PACKAGES\" ]; then\n        echo \"Installing extra packages: $EXTRA_PACKAGES\"\n        pip3 install --no-cache-dir $EXTRA_PACKAGES\n\n        if [ $? -eq 0 ]; then\n            echo \"$CURRENT_PACKAGES\" > \"$INSTALLED_MARKER\"\n            echo \"Extra packages installed successfully\"\n        else\n            echo \"ERROR: Failed to install extra packages\"\n            exit 1\n        fi\n    else\n        echo \"Extra packages already installed: $EXTRA_PACKAGES\"\n    fi\nfi\n\n# Execute the main command\nexec \"$@\"\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "package-lock.json\nnode_modules\n"
  },
  {
    "path": "docs/README.md",
    "content": "Directory of docs\n\n## Regenerating API Documentation\n\n### Modern Interactive API Docs (Recommended)\n\nTo regenerate the modern API documentation, run from the `docs/` directory:\n\n```bash\n# Install dependencies (first time only)\nnpm install\n\n# Generate the HTML documentation from OpenAPI spec using Redoc\nnpm run build-docs\n```\n\n### OpenAPI Specification\n\nThe OpenAPI specification (`docs/api-spec.yaml`) is the source of truth for API documentation. This industry-standard format enables:\n\n- **Interactive documentation** - Test endpoints directly in the browser\n- **SDK generation** - Auto-generate client libraries for any programming language  \n- **API validation** - Ensure code matches documentation\n- **Integration tools** - Import into Postman, Insomnia, API gateways, etc.\n\n**Important:** When adding or modifying API endpoints, you must update `docs/api-spec.yaml` to keep documentation in sync:\n\n1. Edit `docs/api-spec.yaml` with new endpoints, parameters, or response schemas\n2. Run `npm run build-docs` to regenerate the HTML documentation\n3. Commit both the YAML spec and generated HTML files\n\n\n\n"
  },
  {
    "path": "docs/api-spec.yaml",
    "content": "openapi: 3.1.0\ninfo:\n  title: ChangeDetection.io API\n  description: |\n    # ChangeDetection.io Web page monitoring and notifications API\n    \n    REST API for managing Page watches, Group tags, and Notifications.\n    \n    changedetection.io can be driven by its built in simple API, in the examples below you will also find `curl` command line and `python` examples to help you get started faster.\n    \n    ## Where to find my API key?\n    \n    The API key can be easily found under the **SETTINGS** then **API** tab of changedetection.io dashboard.  \n    Simply click the API key to automatically copy it to your clipboard.\n    \n    ![Where to find the API key](./where-to-get-api-key.jpeg)\n    \n    ## Connection URL\n    \n    The API can be found at `/api/v1/`, so for example if you run changedetection.io locally on port 5000, then URL would be `http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history`.\n    \n    If you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example:  \n    `https://<your login url>/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history`\n    \n    ## Authentication\n    \n    Almost all API requests require some authentication, this is provided as an **API Key** in the header of the HTTP request.\n    \n    For example: `x-api-key: YOUR_API_KEY`\n    \n  version: 0.1.6\n  contact:\n    name: ChangeDetection.io\n    url: https://github.com/dgtlmoon/changedetection.io\n  license:\n    name: Apache 2.0\n    url: https://www.apache.org/licenses/LICENSE-2.0.html\n\nservers:\n  - url: http://localhost:5000/api/v1\n    description: Development server\n  - url: https://yourdomain.com/api/v1\n    description: Production server\n  - url: '{protocol}://{host}/api/v1'\n    description: Custom server\n    variables:\n      protocol:\n        enum:\n          - http\n          - https\n        default: https\n      host:\n        default: yourdomain.com\n        description: Your changedetection.io host\n\nsecurity:\n  - ApiKeyAuth: []\n\ntags:\n  - name: Watch Management\n    description: |\n      Core functionality for managing web page monitors. Create, retrieve, update, and delete individual watches. \n      Each watch represents a single URL being monitored for changes, with configurable settings for check intervals, \n      notification preferences, and content filtering options.\n      \n  - name: Watch History\n    description: |\n      Get a list of timestamps of all changes detected for a watch.\n      \n  - name: Snapshots\n    description: |\n      Retrieve individual text snapshot of monitored content according to the `timestamp`. The text snapshot is the HTML\n      to Text at page check time. \n      \n      Set the query argument `html` to any value to retrieve the last HTML fetched, the system only keeps the last two \n      (2) HTML files fetched.\n      \n      Use the Watch History API endpoint to get a list of timestamps to pass to this query.\n      \n  - name: Favicon\n    description: |\n      Retrieve favicon images associated with monitored web pages. These are used in the dashboard interface \n      to visually identify different watches in your monitoring list.\n      \n  - name: Group / Tag Management\n    description: |\n      Organize your watches using tags and groups. Tags (also known as Groups) allow you to categorize monitors, set group-wide \n      notification preferences, and perform bulk operations like mass rechecking or status changes across \n      multiple related watches.\n      \n  - name: Notifications\n    description: |\n      Configure global notification endpoints that can be used across all your watches. Supports various \n      notification services including email, Discord, Slack, webhooks, and many other popular platforms. \n      These settings serve as defaults that can be overridden at the individual watch or tag level.\n\n      The notification syntax uses [https://github.com/caronc/apprise](https://github.com/caronc/apprise).\n      \n  - name: Search\n    description: |\n      Search and filter your watches by URL patterns, titles, or tags. Useful for quickly finding specific \n      monitors in large collections or identifying watches that match certain criteria.\n      \n  - name: Import\n    description: |\n      Bulk import multiple URLs for monitoring. Accepts plain text lists of URLs and can automatically \n      apply tags, proxy settings, and other configurations to all imported watches simultaneously.\n      \n  - name: System Information\n    description: |\n      Retrieve system status and statistics about your changedetection.io instance, including total watch\n      counts, uptime information, and version details.\n\n  - name: Plugin API Extensions\n    description: |\n      ## How Processor Plugins Extend the API\n\n      changedetection.io uses a **processor plugin** system to handle different types of change detection.\n      Each processor lives in `changedetectionio/processors/<name>/` and may include an `api.yaml` file\n      that extends the core Watch schema with processor-specific configuration fields.\n\n      ### How it works\n\n      At startup, changedetection.io scans all installed processors for an `api.yaml` file. Any schemas\n      and code samples defined there are deep-merged into the live API specification, making the\n      processor's configuration fields valid on all watch create and update requests.\n\n      The live, fully-merged spec is always available at `/api/v1/full-spec` — use that URL with\n      Swagger UI or Redoc to see the complete schema for your specific installation.\n\n      ---\n\n      ### Writing a processor `api.yaml`\n\n      Place an `api.yaml` in the processor plugin's own directory, alongside its `__init__.py`\n      (e.g. `changedetectionio/processors/my_processor/api.yaml`). The schema name **must** follow the\n      convention `processor_config_<processor_name>` (e.g. `processor_config_restock_diff`). That same\n      key is used as the JSON field name when creating or updating a watch.\n\n      A minimal `api.yaml` for a hypothetical `my_processor`:\n\n      ```yaml\n      components:\n        schemas:\n          processor_config_my_processor:\n            type: object\n            description: Configuration for my_processor\n            properties:\n              some_option:\n                type: boolean\n                default: true\n                description: Enable some behaviour\n\n      paths:\n        /watch:\n          post:\n            x-code-samples:\n              - lang: curl\n                label: my_processor example\n                source: |\n                  curl -X POST \"http://localhost:5000/api/v1/watch\" \\\n                    -H \"x-api-key: YOUR_API_KEY\" \\\n                    -H \"Content-Type: application/json\" \\\n                    -d '{\n                      \"url\": \"https://example.com\",\n                      \"processor\": \"my_processor\",\n                      \"processor_config_my_processor\": { \"some_option\": true }\n                    }'\n      ```\n\n      The `paths` section in `api.yaml` is used only for injecting additional `x-code-samples` into\n      existing endpoints — you cannot define new routes via plugin.\n\n      ---\n\n      ### Built-in plugin: `restock_diff`\n\n      The `restock_diff` processor is always shipped with changedetection.io. It monitors product\n      availability and price changes using structured data (JSON-LD / schema.org microdata) and\n      text heuristics. It is activated by setting `\"processor\": \"restock_diff\"` on a watch.\n\n      It adds the `processor_config_restock_diff` block to the Watch schema with these fields:\n\n      | Field | Type | Default | Description |\n      |---|---|---|---|\n      | `in_stock_processing` | string | `in_stock_only` | `in_stock_only` — only alert Out-of-Stock→In-Stock · `all_changes` — alert any availability change · `off` — disable stock tracking |\n      | `follow_price_changes` | boolean | `true` | Monitor and alert on price changes |\n      | `price_change_min` | number\\|null | — | Alert when price drops **below** this value |\n      | `price_change_max` | number\\|null | — | Alert when price rises **above** this value |\n      | `price_change_threshold_percent` | number\\|null | — | Minimum % change since the original price to trigger an alert |\n\n      #### CREATE — Add a restock/price monitor\n\n      ```bash\n      curl -X POST \"http://localhost:5000/api/v1/watch\" \\\n        -H \"x-api-key: YOUR_API_KEY\" \\\n        -H \"Content-Type: application/json\" \\\n        -d '{\n          \"url\": \"https://example.com/product/widget\",\n          \"processor\": \"restock_diff\",\n          \"processor_config_restock_diff\": {\n            \"in_stock_processing\": \"in_stock_only\",\n            \"follow_price_changes\": true,\n            \"price_change_threshold_percent\": 5\n          }\n        }'\n      ```\n\n      #### READ — Retrieve the monitor\n\n      The response JSON includes `processor_config_restock_diff` alongside all standard watch fields:\n\n      ```bash\n      curl -X GET \"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\" \\\n        -H \"x-api-key: YOUR_API_KEY\"\n      ```\n\n      ```json\n      {\n        \"uuid\": \"cc0cfffa-f449-477b-83ea-0caafd1dc091\",\n        \"url\": \"https://example.com/product/widget\",\n        \"processor\": \"restock_diff\",\n        \"processor_config_restock_diff\": {\n          \"in_stock_processing\": \"in_stock_only\",\n          \"follow_price_changes\": true,\n          \"price_change_threshold_percent\": 5,\n          \"price_change_min\": null,\n          \"price_change_max\": null\n        }\n      }\n      ```\n\n      #### UPDATE — Change thresholds without recreating the monitor\n\n      Only fields included in the request body are updated; omitted fields are left unchanged.\n\n      ```bash\n      curl -X PUT \"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\" \\\n        -H \"x-api-key: YOUR_API_KEY\" \\\n        -H \"Content-Type: application/json\" \\\n        -d '{\n          \"processor_config_restock_diff\": {\n            \"in_stock_processing\": \"all_changes\",\n            \"follow_price_changes\": true,\n            \"price_change_min\": 10.00,\n            \"price_change_max\": 500.00\n          }\n        }'\n      ```\n\n      #### DELETE — Remove the monitor\n\n      ```bash\n      curl -X DELETE \"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\" \\\n        -H \"x-api-key: YOUR_API_KEY\"\n      ```\n\n      ---\n\n      For the complete schema-validated documentation including all processor fields, fetch the live spec\n      and load it into Swagger UI or Redoc:\n\n      ```\n      GET /api/v1/full-spec\n      ```\n\ncomponents:\n  securitySchemes:\n    ApiKeyAuth:\n      type: apiKey\n      in: header\n      name: x-api-key\n      description: |\n        API key for authentication. You can find your API key in the changedetection.io dashboard under Settings > API.\n        \n        Enter your API key in the \"Authorize\" button above to automatically populate all code examples.\n\n  schemas:\n    WatchBase:\n      type: object\n      properties:\n        uuid:\n          type: string\n          format: uuid\n          description: Unique identifier\n          readOnly: true\n        date_created:\n          type: [integer, 'null']\n          description: Unix timestamp of creation\n          readOnly: true\n        url:\n          type: string\n          format: uri\n          description: URL to monitor for changes\n          maxLength: 5000\n        title:\n          type: [string, 'null']\n          description: Custom title for the web page change monitor (watch), not to be confused with page_title\n          maxLength: 5000\n        tag:\n          type: string\n          description: Tag UUID to associate with this web page change monitor (watch)\n          maxLength: 5000\n        tags:\n          type: array\n          items:\n            type: string\n          description: Array of tag UUIDs\n        paused:\n          type: boolean\n          description: Whether the web page change monitor (watch) is paused\n        notification_muted:\n          type: boolean\n          description: Whether notifications are muted\n        method:\n          type: string\n          enum: [GET, POST, DELETE, PUT]\n          description: HTTP method to use\n        fetch_backend:\n          type: string\n          description: |\n            Backend to use for fetching content. Common values:\n            - `system` (default) - Use the system-wide default fetcher\n            - `html_requests` - Fast requests-based fetcher\n            - `html_webdriver` - Browser-based fetcher (Playwright/Puppeteer)\n            - `extra_browser_*` - Custom browser configurations (if configured)\n            - Plugin-provided fetchers (if installed)\n          pattern: '^(system|html_requests|html_webdriver|extra_browser_.+)$'\n          default: system\n        headers:\n          type: object\n          additionalProperties:\n            type: string\n          description: HTTP headers to include in requests\n        body:\n          type: [string, 'null']\n          description: HTTP request body\n          maxLength: 5000\n        proxy:\n          type: [string, 'null']\n          description: Proxy configuration\n          maxLength: 5000\n        ignore_status_codes:\n          type: [boolean, 'null']\n          description: Ignore HTTP status code errors (boolean or null)\n        webdriver_delay:\n          type: [integer, 'null']\n          description: Delay in seconds for webdriver\n        webdriver_js_execute_code:\n          type: [string, 'null']\n          description: JavaScript code to execute\n          maxLength: 5000\n        time_between_check:\n          type: object\n          properties:\n            weeks:\n              type: [integer, 'null']\n              minimum: 0\n              maximum: 52000\n            days:\n              type: [integer, 'null']\n              minimum: 0\n              maximum: 365000\n            hours:\n              type: [integer, 'null']\n              minimum: 0\n              maximum: 8760000\n            minutes:\n              type: [integer, 'null']\n              minimum: 0\n              maximum: 525600000\n            seconds:\n              type: [integer, 'null']\n              minimum: 0\n              maximum: 31536000000\n          description: Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.\n        time_between_check_use_default:\n          type: boolean\n          default: true\n          description: Whether to use global settings for time between checks - defaults to true if not set\n        notification_urls:\n          type: array\n          items:\n            type: string\n            maxLength: 1000\n          maxItems: 100\n          description: Notification URLs for this web page change monitor (watch). Maximum 100 URLs.\n        notification_title:\n          type: [string, 'null']\n          description: Custom notification title\n          maxLength: 5000\n        notification_body:\n          type: [string, 'null']\n          description: Custom notification body\n          maxLength: 5000\n        notification_format:\n          type: string\n          enum: ['text', 'html', 'htmlcolor', 'markdown', 'System default']\n          description: Format for notifications\n        track_ldjson_price_data:\n          type: [boolean, 'null']\n          description: Whether to track JSON-LD price data\n        browser_steps:\n          type: array\n          items:\n            type: object\n            properties:\n              operation:\n                type: [string, 'null']\n                maxLength: 5000\n              selector:\n                type: [string, 'null']\n                maxLength: 5000\n              optional_value:\n                type: [string, 'null']\n                maxLength: 5000\n            required: [operation, selector, optional_value]\n            additionalProperties: false\n          maxItems: 100\n          description: Browser automation steps. Maximum 100 steps allowed.\n        processor:\n          type: string\n          enum: [restock_diff, text_json_diff]\n          default: text_json_diff\n          description: Optional processor mode to use for change detection. Defaults to `text_json_diff` if not specified.\n\n        # Content Filtering\n        include_filters:\n          type: array\n          items:\n            type: string\n            maxLength: 5000\n          maxItems: 100\n          description: CSS/XPath selectors to extract specific content from the page\n        subtractive_selectors:\n          type: array\n          items:\n            type: string\n            maxLength: 5000\n          maxItems: 100\n          description: CSS/XPath selectors to remove content from the page\n        ignore_text:\n          type: array\n          items:\n            type: string\n            maxLength: 5000\n          maxItems: 100\n          description: Text patterns to ignore in change detection\n        trigger_text:\n          type: array\n          items:\n            type: string\n            maxLength: 5000\n          maxItems: 100\n          description: Text/regex patterns that must be present to trigger a change\n        text_should_not_be_present:\n          type: array\n          items:\n            type: string\n            maxLength: 5000\n          maxItems: 100\n          description: Text that should NOT be present (triggers alert if found)\n        extract_text:\n          type: array\n          items:\n            type: string\n            maxLength: 5000\n          maxItems: 100\n          description: Regex patterns to extract specific text after filtering\n\n        # Text Processing\n        trim_text_whitespace:\n          type: boolean\n          default: false\n          description: Strip leading/trailing whitespace from text\n        sort_text_alphabetically:\n          type: boolean\n          default: false\n          description: Sort lines alphabetically before comparison\n        remove_duplicate_lines:\n          type: boolean\n          default: false\n          description: Remove duplicate lines from content\n        check_unique_lines:\n          type: boolean\n          default: false\n          description: Compare against all history for unique lines\n        strip_ignored_lines:\n          type: [boolean, 'null']\n          description: Remove lines matching ignore patterns\n\n        # Change Detection Filters\n        filter_text_added:\n          type: boolean\n          default: true\n          description: Include added text in change detection\n        filter_text_removed:\n          type: boolean\n          default: true\n          description: Include removed text in change detection\n        filter_text_replaced:\n          type: boolean\n          default: true\n          description: Include replaced text in change detection\n\n        # Restock/Price Detection\n        in_stock_only:\n          type: boolean\n          default: true\n          description: Only trigger on in-stock transitions (restock_diff processor)\n        follow_price_changes:\n          type: boolean\n          default: true\n          description: Monitor and track price changes (restock_diff processor)\n        price_change_threshold_percent:\n          type: [number, 'null']\n          description: Minimum price change percentage to trigger notification\n        has_ldjson_price_data:\n          type: [boolean, 'null']\n          description: Whether page has LD-JSON price data (auto-detected)\n          readOnly: true\n\n        # Notifications\n        notification_screenshot:\n          type: boolean\n          default: false\n          description: Include screenshot in notifications (if supported by notification URL)\n        filter_failure_notification_send:\n          type: boolean\n          default: true\n          description: Send notification when filters fail to match content\n\n        # History & Display\n        use_page_title_in_list:\n          type: [boolean, 'null']\n          description: Display page title in watch list (null = use system default)\n        history_snapshot_max_length:\n          type: [integer, 'null']\n          minimum: 1\n          maximum: 1000\n          description: Maximum number of history snapshots to keep (null = use system default)\n\n        # Scheduling\n        time_schedule_limit:\n          type: object\n          description: Weekly schedule limiting when checks can run\n          properties:\n            enabled:\n              type: boolean\n              default: false\n            monday:\n              $ref: '#/components/schemas/DaySchedule'\n            tuesday:\n              $ref: '#/components/schemas/DaySchedule'\n            wednesday:\n              $ref: '#/components/schemas/DaySchedule'\n            thursday:\n              $ref: '#/components/schemas/DaySchedule'\n            friday:\n              $ref: '#/components/schemas/DaySchedule'\n            saturday:\n              $ref: '#/components/schemas/DaySchedule'\n            sunday:\n              $ref: '#/components/schemas/DaySchedule'\n\n        # Conditions (advanced logic)\n        conditions:\n          type: array\n          items:\n            type: object\n            properties:\n              field:\n                type: string\n                description: Field to check (e.g., 'page_filtered_text', 'page_title')\n              operator:\n                type: string\n                description: Comparison operator (e.g., 'contains_regex', 'equals', 'not_equals')\n              value:\n                type: string\n                description: Value to compare against\n            required: [field, operator, value]\n          maxItems: 100\n          description: Array of condition rules for change detection logic (empty array when not set)\n        conditions_match_logic:\n          type: string\n          enum: ['ALL', 'ANY']\n          default: 'ALL'\n          description: Logic operator - ALL (match all conditions) or ANY (match any condition)\n\n    DaySchedule:\n      type: object\n      properties:\n        enabled:\n          type: boolean\n          default: true\n        start_time:\n          type: string\n          pattern: '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'\n          default: '00:00'\n          description: Start time in HH:MM format\n        duration:\n          type: object\n          properties:\n            hours:\n              type: string\n              pattern: '^[0-9]+$'\n              default: '24'\n            minutes:\n              type: string\n              pattern: '^[0-9]+$'\n              default: '00'\n\n    Watch:\n      allOf:\n        - $ref: '#/components/schemas/WatchBase'\n        - type: object\n          properties:\n            last_checked:\n              type: integer\n              description: Unix timestamp of last check\n              readOnly: true\n            last_changed:\n              type: integer\n              description: Unix timestamp of last change\n              readOnly: true\n              x-computed: true\n            last_error:\n              type: [string, boolean, 'null']\n              description: Last error message (false when no error, string when error occurred, null if not checked yet)\n              readOnly: true\n            last_viewed:\n              type: integer\n              description: Unix timestamp in seconds of the last time the watch was viewed. Setting it to a value higher than `last_changed` in the \"Update watch\" endpoint marks the watch as viewed.\n              minimum: 0\n            link:\n              type: string\n              format: string\n              description: The watch URL rendered in case of any Jinja2 markup, always use this for listing.\n              readOnly: true\n              x-computed: true\n            page_title:\n              type: [string, 'null']\n              description: HTML <title> tag extracted from the page\n              readOnly: true\n            check_count:\n              type: integer\n              description: Total number of checks performed\n              readOnly: true\n            fetch_time:\n              type: number\n              description: Duration of last fetch in seconds\n              readOnly: true\n            previous_md5:\n              type: [string, boolean]\n              description: MD5 hash of previous content (false if not set)\n              readOnly: true\n            previous_md5_before_filters:\n              type: [string, boolean]\n              description: MD5 hash before filters applied (false if not set)\n              readOnly: true\n            consecutive_filter_failures:\n              type: integer\n              description: Counter for consecutive filter match failures\n              readOnly: true\n            last_notification_error:\n              type: [string, 'null']\n              description: Last notification error message\n              readOnly: true\n            notification_alert_count:\n              type: integer\n              description: Number of notifications sent\n              readOnly: true\n            content-type:\n              type: [string, 'null']\n              description: Content-Type from last fetch\n              readOnly: true\n            remote_server_reply:\n              type: [string, 'null']\n              description: Server header from last response\n              readOnly: true\n            browser_steps_last_error_step:\n              type: [integer, 'null']\n              description: Last browser step that caused an error\n              readOnly: true\n            viewed:\n              type: [integer, boolean]\n              description: Computed property - true if watch has been viewed, false otherwise (deprecated, use last_viewed instead)\n              readOnly: true\n              x-computed: true\n            history_n:\n              type: integer\n              description: Number of history snapshots available\n              readOnly: true\n              x-computed: true\n\n    CreateWatch:\n      allOf:\n        - $ref: '#/components/schemas/WatchBase'\n        - type: 'object'\n          required:\n            - url\n\n    UpdateWatch:\n      allOf:\n        - $ref: '#/components/schemas/WatchBase'  # Extends WatchBase for user-settable fields\n        - type: object\n          properties:\n            last_viewed:\n              type: integer\n              description: Unix timestamp in seconds of the last time the watch was viewed. Setting it to a value higher than `last_changed` in the \"Update watch\" endpoint marks the watch as viewed.\n              minimum: 0\n      # Note: ReadOnly and @property fields are filtered out in the backend before update\n      # We don't use unevaluatedProperties:false here to allow roundtrip GET/PUT workflows\n      # where the response includes computed fields that should be silently ignored\n\n    Tag:\n      allOf:\n        - $ref: '#/components/schemas/WatchBase'\n        - type: object\n          properties:\n            overrides_watch:\n              type: [boolean, 'null']\n              description: |\n                Whether this tag's settings override watch settings for all watches in this tag/group.\n                - true: Tag settings override watch settings\n                - false: Tag settings do not override (watches use their own settings)\n                - null: Not decided yet / inherit default behavior\n            # Future: Aggregated statistics from all watches with this tag\n            # check_count:\n            #   type: integer\n            #   description: Sum of check_count from all watches with this tag\n            #   readOnly: true\n            #   x-computed: true\n            # last_checked:\n            #   type: integer\n            #   description: Most recent last_checked timestamp from all watches with this tag\n            #   readOnly: true\n            #   x-computed: true\n            # last_changed:\n            #   type: integer\n            #   description: Most recent last_changed timestamp from all watches with this tag\n            #   readOnly: true\n            #   x-computed: true\n\n    CreateTag:\n      allOf:\n        - $ref: '#/components/schemas/Tag'\n        - type: object\n          required:\n            - title\n\n    NotificationUrls:\n      type: object\n      properties:\n        notification_urls:\n          type: array\n          items:\n            type: string\n            format: uri\n          description: List of notification URLs\n      required:\n        - notification_urls\n\n    SystemInfo:\n      type: object\n      properties:\n        watch_count:\n          type: integer\n          description: Total number of web page change monitors (watches)\n        tag_count:\n          type: integer\n          description: Total number of tags\n        uptime:\n          type: string\n          description: System uptime\n        version:\n          type: string\n          description: Application version\n\n    SearchResult:\n      type: object\n      properties:\n        watches:\n          type: object\n          additionalProperties:\n            $ref: '#/components/schemas/Watch'\n          description: Dictionary of matching web page change monitors (watches) keyed by UUID\n\n    WatchHistory:\n      type: object\n      additionalProperties:\n        type: string\n        description: Path to snapshot file\n      description: Dictionary of timestamps and snapshot paths\n\n    Error:\n      type: object\n      properties:\n        message:\n          type: string\n          description: Error message\n\npaths:\n  /watch:\n    get:\n      operationId: listWatches\n      tags: [Watch Management]\n      summary: List all watches\n      description: Return concise list of available web page change monitors (watches) and basic info\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/watch\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            response = requests.get('http://localhost:5000/api/v1/watch', headers=headers)\n            print(response.json())\n      parameters:\n        - name: recheck_all\n          in: query\n          description: Set to 1 to force recheck of all watches\n          schema:\n            type: string\n            enum: [\"1\"]\n        - name: tag\n          in: query\n          description: Tag name to filter results\n          schema:\n            type: string\n      responses:\n        '200':\n          description: List of watches\n          content:\n            application/json:\n              schema:\n                type: object\n                additionalProperties:\n                  $ref: '#/components/schemas/Watch'\n              example:\n                \"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\":\n                  uuid: \"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\"\n                  url: \"http://example.com?id={{1+1}} - the raw URL\"\n                  link: \"http://example.com?id=2 - the rendered URL, always use this for listing.\"\n                  title: \"Example Website Monitor - manually entered title/description\"\n                  page_title: \"The HTML <title> from the page\"\n                  tags: [\"550e8400-e29b-41d4-a716-446655440000\"]\n                  paused: false\n                  notification_muted: false\n                  method: \"GET\"\n                  fetch_backend: \"html_requests\"\n                  last_checked: 1640995200\n                  last_changed: 1640995200\n                \"7c9e6b8d-f2a1-4e5c-9d3b-8a7f6e4c2d1a\":\n                  uuid: \"7c9e6b8d-f2a1-4e5c-9d3b-8a7f6e4c2d1a\"\n                  url: \"http://example.com?id={{1+1}} - the raw URL\"\n                  link: \"http://example.com?id=2 - the rendered URL, always use this for listing.\"\n                  title: \"News Site Tracker - manually entered title/description\"\n                  page_title: \"The HTML <title> from the page\"\n                  tags: [\"330e8400-e29b-41d4-a716-446655440001\"]\n                  paused: false\n                  notification_muted: true\n                  method: \"GET\"\n                  fetch_backend: \"html_webdriver\"\n                  last_checked: 1640998800\n                  last_changed: 1640995200\n    post:\n      operationId: createWatch\n      tags: [Watch Management]\n      summary: Create a new watch\n      description: |\n        Create a single web page change monitor (watch). Requires at least `url` to be set.\n\n        Every watch can be configured with:\n        - **Processor mode**: `processor` field (`restock_diff` or `text_json_diff` - default)\n        - **Notification settings**: `notification_urls` (array), `notification_title`, `notification_body`, `notification_format`, `notification_muted`\n        - **Tags/Groups**: `tag` (UUID string) or `tags` (array of UUIDs)\n        - **Check settings**: `time_between_check`, `paused`, `method`, `fetch_backend`\n        - **Advanced options**: `headers`, `body`, `proxy`, `browser_steps`, and more\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X POST \"http://localhost:5000/api/v1/watch\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"url\": \"https://example.com\",\n                \"title\": \"Example Site Monitor\",\n                \"time_between_check\": {\n                  \"hours\": 1\n                }\n              }'\n        - lang: 'Python'\n          source: |\n            import requests\n            import json\n\n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            data = {\n                'url': 'https://example.com',\n                'title': 'Example Site Monitor',\n                'time_between_check': {\n                    'hours': 1\n                }\n            }\n            response = requests.post('http://localhost:5000/api/v1/watch',\n                                   headers=headers, json=data)\n            print(response.text)\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/CreateWatch'\n            example:\n              url: \"https://example.com\"\n              title: \"Example Site Monitor\"\n              time_between_check:\n                hours: 1\n      responses:\n        '200':\n          description: Web page change monitor (watch) created successfully\n          content:\n            text/plain:\n              schema:\n                type: string\n                example: \"OK\"\n        '500':\n          description: Server error\n          content:\n            text/plain:\n              schema:\n                type: string\n\n  /watch/{uuid}:\n    get:\n      operationId: getWatch\n      tags: [Watch Management]\n      summary: Get single watch\n      description: Retrieve web page change monitor (watch) information and set muted/paused status. Returns the FULL Watch JSON.\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            uuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\n            response = requests.get(f'http://localhost:5000/api/v1/watch/{uuid}', headers=headers)\n            print(response.json())\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Web page change monitor (watch) unique ID\n          schema:\n            type: string\n            format: uuid\n        - name: recheck\n          in: query\n          description: Recheck this web page change monitor (watch)\n          schema:\n            type: string\n            enum: [\"1\", \"true\"]\n        - name: paused\n          in: query\n          description: Set pause state\n          schema:\n            type: string\n            enum: [paused, unpaused]\n        - name: muted\n          in: query\n          description: Set mute state\n          schema:\n            type: string\n            enum: [muted, unmuted]\n      responses:\n        '200':\n          description: Watch information or operation result\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/Watch'\n            text/plain:\n              schema:\n                type: string\n                example: \"OK\"\n        '404':\n          description: Web page change monitor (watch) not found\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/Error'\n\n    put:\n      operationId: updateWatch\n      tags: [Watch Management]\n      summary: Update watch\n      description: Update an existing web page change monitor (watch) using JSON. Accepts the same structure as returned in [get single watch information](#operation/getWatch).\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X PUT \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"url\": \"https://updated-example.com\",\n                \"title\": \"Updated Monitor\",\n                \"paused\": false\n              }'\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            uuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\n            data = {\n                'url': 'https://updated-example.com',\n                'title': 'Updated Monitor',\n                'paused': False\n            }\n            response = requests.put(f'http://localhost:5000/api/v1/watch/{uuid}', \n                                  headers=headers, json=data)\n            print(response.text)\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Web page change monitor (watch) unique ID\n          schema:\n            type: string\n            format: uuid\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/UpdateWatch'\n      responses:\n        '200':\n          description: Web page change monitor (watch) updated successfully\n          content:\n            text/plain:\n              schema:\n                type: string\n                example: \"OK\"\n        '500':\n          description: Server error\n\n    delete:\n      operationId: deleteWatch\n      tags: [Watch Management]\n      summary: Delete watch\n      description: Delete a web page change monitor (watch) and all related history\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X DELETE \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            uuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\n            response = requests.delete(f'http://localhost:5000/api/v1/watch/{uuid}', headers=headers)\n            print(response.text)\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Web page change monitor (watch) unique ID\n          schema:\n            type: string\n            format: uuid\n      responses:\n        '200':\n          description: Web page change monitor (watch) deleted successfully\n          content:\n            text/plain:\n              schema:\n                type: string\n                example: \"OK\"\n\n  /watch/{uuid}/history:\n    get:\n      operationId: getWatchHistory\n      tags: [Watch History]\n      summary: Get watch history\n      description: | \n        Get a list of all historical snapshots available for a web page change monitor (watch), use the key `timestamp`\n        as the query argument for fetching a single watch history snapshot.\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/history\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            uuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\n            response = requests.get(f'http://localhost:5000/api/v1/watch/{uuid}/history', headers=headers)\n            print(response.json())\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Web page change monitor (watch) unique ID\n          schema:\n            type: string\n            format: uuid\n      responses:\n        '200':\n          description: List of available snapshots\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/WatchHistory'\n              example:\n                \"1640995200\": \"/path/to/snapshot1.txt\"\n                \"1640998800\": \"/path/to/snapshot2.txt\"\n        '404':\n          description: Web page change monitor (watch) not found\n\n  /watch/{uuid}/history/{timestamp}:\n    get:\n      operationId: getWatchSnapshot\n      tags: [Snapshots]\n      summary: Get single snapshot\n      description: | \n        Get single snapshot from web page change monitor (watch). Use 'latest' for the most recent snapshot.\n        Use the Watch History API to get a list of timestamps to pass.\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/history/latest\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            uuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\n            timestamp = 'latest'  # or use specific timestamp like 1640995200\n            response = requests.get(f'http://localhost:5000/api/v1/watch/{uuid}/history/{timestamp}', headers=headers)\n            print(response.text)\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Web page change monitor (watch) unique ID\n          schema:\n            type: string\n            format: uuid\n        - name: timestamp\n          in: path\n          required: true\n          description: Snapshot timestamp or 'latest'\n          schema:\n            oneOf:\n              - type: integer\n              - type: string\n                enum: [latest]\n        - name: html\n          in: query\n          description: Set to 1 to return the last HTML\n          schema:\n            type: string\n            enum: [\"1\"]\n      responses:\n        '200':\n          description: Snapshot content\n          content:\n            text/plain:\n              schema:\n                type: string\n        '404':\n          description: Snapshot not found\n\n  /watch/{uuid}/difference/{from_timestamp}/{to_timestamp}:\n    get:\n      operationId: getWatchHistoryDiff\n      tags: [Watch History]\n      summary: Get the difference between two snapshots\n      description: |\n        Generate a difference (comparison) between two historical snapshots of a web page change monitor (watch).\n\n        This endpoint compares content between two points in time and returns the differences in your chosen format.\n        Perfect for reviewing what changed between specific versions or comparing recent changes.\n\n        **Timestamp Keywords:**\n        - Use `'latest'` for the most recent snapshot (to_timestamp)\n        - Use `'previous'` for the second-most-recent snapshot (from_timestamp)\n        - Or use specific Unix timestamps from the watch history\n\n        **Format Options:**\n        - `text` (default): Plain text with (removed) and (added) prefixes\n        - `html`: HTML format with (removed) and (added) text\n        - `htmlcolor`: Rich HTML with colored highlights (green for additions, red for deletions)\n\n        **Word-Level Diffing:**\n        - Enable word-level granularity with `word_diff=true` for detailed inline comparisons\n        - Disable with `word_diff=false` for line-level comparisons only (default false/off, line-level mode by default)\n\n        **Raw Diff Output:**\n        - Use `no_markup=true` to get raw diff content without any formatting applied\n        - Returns content with placeholders for opening/closing tags of changes\n        - Allows you to implement your own custom colorisation or formatting\n        - Skips all HTML color application and service tweaks (added text, html color tags, etc)\n\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            # Compare previous snapshot to latest with colored HTML\n            curl -X GET \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/previous/latest?format=htmlcolor\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n\n            # Compare two specific timestamps in plain text with word-level diff\n            curl -X GET \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/1640995200/1640998800?format=text&word_diff=true\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n\n            # Show only additions (hide removed/replaced content), ignore whitespace\n            curl -X GET \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/previous/latest?format=htmlcolor&removed=false&replaced=false&ignoreWhitespace=true\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n\n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            uuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\n\n            # Compare previous to latest with colored HTML output\n            response = requests.get(\n                f'http://localhost:5000/api/v1/watch/{uuid}/difference/previous/latest',\n                headers=headers,\n                params={'format': 'htmlcolor'}\n            )\n            print(response.text)\n\n            # Compare specific timestamps with word-level diff\n            from_ts = '1640995200'\n            to_ts = '1640998800'\n            response = requests.get(\n                f'http://localhost:5000/api/v1/watch/{uuid}/difference/{from_ts}/{to_ts}',\n                headers=headers,\n                params={'format': 'text', 'word_diff': 'true'}\n            )\n            print(response.text)\n\n            # Show only additions, ignore whitespace and use word-level diff\n            response = requests.get(\n                f'http://localhost:5000/api/v1/watch/{uuid}/difference/previous/latest',\n                headers=headers,\n                params={\n                    'format': 'htmlcolor',\n                    'type': 'diffWords',\n                    'removed': 'false',\n                    'replaced': 'false',\n                    'ignoreWhitespace': 'true'\n                }\n            )\n            print(response.text)\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Web page change monitor (watch) unique ID\n          schema:\n            type: string\n            format: uuid\n        - name: from_timestamp\n          in: path\n          required: true\n          description: Starting snapshot timestamp, 'previous' for second-most-recent, or specific Unix timestamp\n          schema:\n            oneOf:\n              - type: integer\n                description: Unix timestamp of the starting snapshot\n              - type: string\n                enum: [previous]\n                description: Use 'previous' to automatically select the second-most-recent snapshot\n          example: previous\n        - name: to_timestamp\n          in: path\n          required: true\n          description: Ending snapshot timestamp, 'latest' for most recent, or specific Unix timestamp\n          schema:\n            oneOf:\n              - type: integer\n                description: Unix timestamp of the ending snapshot\n              - type: string\n                enum: [latest]\n                description: Use 'latest' to automatically select the most recent snapshot\n          example: latest\n        - name: format\n          in: query\n          description: |\n            Output format for the diff:\n            - `text` (default): Plain text with (removed) and (added) prefixes\n            - `html`: Basic HTML format\n            - `htmlcolor`: Rich HTML with colored backgrounds (red for deletions, green for additions)\n            - `markdown`: Markdown format with HTML rendering\n          schema:\n            type: string\n            enum: [text, html, htmlcolor, markdown]\n            default: text\n        - name: word_diff\n          in: query\n          description: |\n            Enable word-level diffing for more granular comparisons.\n            When enabled, changes are highlighted at the word level rather than line level.\n            Default is false (line-level mode).\n            Accepts: true, false, 1, 0, yes, no, on, off\n          schema:\n            type: string\n            enum: [\"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"]\n            default: \"false\"\n        - name: no_markup\n          in: query\n          description: |\n            When set to true, returns the raw diff content without any markup formatting.\n            The content will include placeholders for opening/closing tags of the changes,\n            allowing you to implement your own custom colorisation or formatting.\n            This skips all HTML color application and service tweaks.\n            Accepts: true, false, 1, 0, yes, no, on, off\n          schema:\n            type: string\n            enum: [\"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"]\n            default: \"false\"\n        - name: type\n          in: query\n          description: |\n            Diff granularity type:\n            - `diffLines` (default): Line-level comparison, showing which lines changed\n            - `diffWords`: Word-level comparison, showing which words changed within lines\n\n            This parameter is an alternative to `word_diff` for better alignment with the UI.\n            If both are specified, `type=diffWords` will enable word-level diffing.\n          schema:\n            type: string\n            enum: [diffLines, diffWords]\n            default: diffLines\n        - name: changesOnly\n          in: query\n          description: |\n            When enabled, only show lines/content that changed (no surrounding context).\n            When disabled, include unchanged lines for context around changes.\n            Accepts: true, false, 1, 0, yes, no, on, off\n          schema:\n            type: string\n            enum: [\"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"]\n            default: \"true\"\n        - name: ignoreWhitespace\n          in: query\n          description: |\n            When enabled, ignore whitespace-only changes (spaces, tabs, newlines).\n            Useful for focusing on content changes and ignoring formatting differences.\n            Accepts: true, false, 1, 0, yes, no, on, off\n          schema:\n            type: string\n            enum: [\"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"]\n            default: \"false\"\n        - name: removed\n          in: query\n          description: |\n            Include removed/deleted content in the diff output.\n            When disabled, content that was deleted will not appear in the diff.\n            Accepts: true, false, 1, 0, yes, no, on, off\n          schema:\n            type: string\n            enum: [\"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"]\n            default: \"true\"\n        - name: added\n          in: query\n          description: |\n            Include added/new content in the diff output.\n            When disabled, content that was added will not appear in the diff.\n            Accepts: true, false, 1, 0, yes, no, on, off\n          schema:\n            type: string\n            enum: [\"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"]\n            default: \"true\"\n        - name: replaced\n          in: query\n          description: |\n            Include replaced/modified content in the diff output.\n            When disabled, content that was modified (changed from one value to another) will not appear in the diff.\n            Accepts: true, false, 1, 0, yes, no, on, off\n          schema:\n            type: string\n            enum: [\"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"]\n            default: \"true\"\n      responses:\n        '200':\n          description: Formatted diff between the two snapshots\n          content:\n            text/plain:\n              schema:\n                type: string\n                description: Plain text diff with change markers\n            text/html:\n              schema:\n                type: string\n                description: HTML formatted diff with styling\n        '400':\n          description: Invalid format parameter or invalid request\n        '404':\n          description: Watch not found, timestamps not found, or insufficient history\n\n  /watch/{uuid}/favicon:\n    get:\n      operationId: getWatchFavicon\n      tags: [Favicon]\n      summary: Get watch favicon\n      description: Get the favicon for a web page change monitor (watch) as displayed in the watch overview list.\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/favicon\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              --output favicon.ico\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            uuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\n            response = requests.get(f'http://localhost:5000/api/v1/watch/{uuid}/favicon', headers=headers)\n            with open('favicon.ico', 'wb') as f:\n                f.write(response.content)\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Web page change monitor (watch) unique ID\n          schema:\n            type: string\n            format: uuid\n      responses:\n        '200':\n          description: Favicon binary data\n          content:\n            image/*:\n              schema:\n                type: string\n                format: binary\n        '404':\n          description: Favicon not found\n\n  /tags:\n    get:\n      operationId: listTags\n      tags: [Group / Tag Management]\n      summary: List all tags\n      description: Return list of available tags/groups\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/tags\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            response = requests.get('http://localhost:5000/api/v1/tags', headers=headers)\n            print(response.json())\n      responses:\n        '200':\n          description: List of tags\n          content:\n            application/json:\n              schema:\n                type: object\n                additionalProperties:\n                  $ref: '#/components/schemas/Tag'\n              example:\n                \"550e8400-e29b-41d4-a716-446655440000\":\n                  uuid: \"550e8400-e29b-41d4-a716-446655440000\"\n                  title: \"Production Sites\"\n                  notification_urls: [\"mailto:admin@example.com\"]\n                  notification_muted: false\n                \"330e8400-e29b-41d4-a716-446655440001\":\n                  uuid: \"330e8400-e29b-41d4-a716-446655440001\"\n                  title: \"News Sources\"\n                  notification_urls: [\"discord://webhook_id/webhook_token\"]\n                  notification_muted: false\n\n  /tag:\n    post:\n      operationId: createTag\n      tags: [Group / Tag Management]\n      summary: Create tag\n      description: Create a single tag/group\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X POST \"http://localhost:5000/api/v1/tag\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"title\": \"Important Sites\"\n              }'\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            data = {'title': 'Important Sites'}\n            response = requests.post('http://localhost:5000/api/v1/tag',\n                                   headers=headers, json=data)\n            print(response.json())\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/CreateTag'\n            example:\n              title: \"Important Sites\"\n      responses:\n        '201':\n          description: Tag created successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  uuid:\n                    type: string\n                    format: uuid\n                    description: UUID of the created tag\n        '400':\n          description: Invalid or unsupported tag\n\n  /tag/{uuid}:\n    get:\n      operationId: getTag\n      tags: [Group / Tag Management]\n      summary: Get single tag\n      description: Retrieve tag information, set notification_muted status, recheck all web page change monitors (watches) in tag.\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            tag_uuid = '550e8400-e29b-41d4-a716-446655440000'\n            response = requests.get(f'http://localhost:5000/api/v1/tag/{tag_uuid}', headers=headers)\n            print(response.json())\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Tag unique ID\n          schema:\n            type: string\n            format: uuid\n        - name: muted\n          in: query\n          description: Set mute state\n          schema:\n            type: string\n            enum: [muted, unmuted]\n        - name: recheck\n          in: query\n          description: Queue all web page change monitors (watches) with this tag for recheck\n          schema:\n            type: string\n            enum: [\"true\"]\n      responses:\n        '200':\n          description: Tag information or operation result\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/Tag'\n            text/plain:\n              schema:\n                type: string\n                example: \"OK\"\n        '404':\n          description: Tag not found\n\n    put:\n      operationId: updateTag\n      tags: [Group / Tag Management]\n      summary: Update tag\n      description: Update an existing tag using JSON\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X PUT \"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"title\": \"Updated Production Sites\",\n                \"notification_muted\": false\n              }'\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            tag_uuid = '550e8400-e29b-41d4-a716-446655440000'\n            data = {\n                'title': 'Updated Production Sites',\n                'notification_muted': False\n            }\n            response = requests.put(f'http://localhost:5000/api/v1/tag/{tag_uuid}', \n                                  headers=headers, json=data)\n            print(response.text)\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Tag unique ID\n          schema:\n            type: string\n            format: uuid\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/Tag'\n      responses:\n        '200':\n          description: Tag updated successfully\n        '500':\n          description: Server error\n\n    delete:\n      operationId: deleteTag\n      tags: [Group / Tag Management]\n      summary: Delete tag\n      description: Delete a tag/group and remove it from all web page change monitors (watches)\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X DELETE \"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            tag_uuid = '550e8400-e29b-41d4-a716-446655440000'\n            response = requests.delete(f'http://localhost:5000/api/v1/tag/{tag_uuid}', headers=headers)\n            print(response.text)\n      parameters:\n        - name: uuid\n          in: path\n          required: true\n          description: Tag unique ID\n          schema:\n            type: string\n            format: uuid\n      responses:\n        '200':\n          description: Tag deleted successfully\n\n\n  /notifications:\n    get:\n      operationId: getNotifications\n      tags: [Notifications]\n      summary: Get notification URLs\n      description: Return the notification URL list from the configuration\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/notifications\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            response = requests.get('http://localhost:5000/api/v1/notifications', headers=headers)\n            print(response.json())\n      responses:\n        '200':\n          description: List of notification URLs\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/NotificationUrls'\n\n    post:\n      operationId: addNotifications\n      tags: [Notifications]\n      summary: Add notification URLs\n      description: Add one or more notification URLs to the configuration\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X POST \"http://localhost:5000/api/v1/notifications\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"notification_urls\": [\n                  \"mailto:admin@example.com\",\n                  \"discord://webhook_id/webhook_token\"\n                ]\n              }'\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            data = {\n                'notification_urls': [\n                    'mailto:admin@example.com',\n                    'discord://webhook_id/webhook_token'\n                ]\n            }\n            response = requests.post('http://localhost:5000/api/v1/notifications', \n                                   headers=headers, json=data)\n            print(response.json())\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/NotificationUrls'\n            example:\n              notification_urls:\n                - \"mailto:admin@example.com\"\n                - \"discord://webhook_id/webhook_token\"\n      responses:\n        '201':\n          description: Notification URLs added successfully\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/NotificationUrls'\n        '400':\n          description: Invalid input\n\n    put:\n      operationId: replaceNotifications\n      tags: [Notifications]\n      summary: Replace notification URLs\n      description: Replace all notification URLs with the provided list (can be empty)\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X PUT \"http://localhost:5000/api/v1/notifications\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"notification_urls\": [\n                  \"mailto:newadmin@example.com\"\n                ]\n              }'\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            data = {\n                'notification_urls': [\n                    'mailto:newadmin@example.com'\n                ]\n            }\n            response = requests.put('http://localhost:5000/api/v1/notifications', \n                                  headers=headers, json=data)\n            print(response.json())\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/NotificationUrls'\n      responses:\n        '200':\n          description: Notification URLs replaced successfully\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/NotificationUrls'\n        '400':\n          description: Invalid input\n\n    delete:\n      operationId: deleteNotifications\n      tags: [Notifications]\n      summary: Delete notification URLs\n      description: Delete one or more notification URLs from the configuration\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X DELETE \"http://localhost:5000/api/v1/notifications\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"notification_urls\": [\n                  \"mailto:admin@example.com\"\n                ]\n              }'\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'application/json'\n            }\n            data = {\n                'notification_urls': [\n                    'mailto:admin@example.com'\n                ]\n            }\n            response = requests.delete('http://localhost:5000/api/v1/notifications', \n                                     headers=headers, json=data)\n            print(response.status_code)\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/NotificationUrls'\n      responses:\n        '204':\n          description: Notification URLs deleted successfully\n        '400':\n          description: No matching notification URLs found\n\n  /search:\n    get:\n      operationId: searchWatches\n      tags: [Search]\n      summary: Search watches\n      description: Search web page change monitors (watches) by URL or title text\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/search?q=example.com\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n            \n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            params = {'q': 'example.com'}\n            response = requests.get('http://localhost:5000/api/v1/search', \n                                  headers=headers, params=params)\n            print(response.json())\n      parameters:\n        - name: q\n          in: query\n          required: true\n          description: Search query to match against watch URLs and titles\n          schema:\n            type: string\n        - name: tag\n          in: query\n          description: Tag name to limit results (name not UUID)\n          schema:\n            type: string\n        - name: partial\n          in: query\n          description: Allow partial matching of URL query\n          schema:\n            type: string\n      responses:\n        '200':\n          description: Search results\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SearchResult'\n              example:\n                watches:\n                  \"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\":\n                    uuid: \"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\"\n                    url: \"http://example.com\"\n                    title: \"Example Website Monitor\"\n                    tags: [\"550e8400-e29b-41d4-a716-446655440000\"]\n                    paused: false\n                    notification_muted: false\n\n  /import:\n    post:\n      operationId: importWatches\n      tags: [Import]\n      summary: Import watch URLs with configuration\n      description: |\n        Import a list of URLs to monitor with optional watch configuration. Accepts line-separated URLs in request body.\n\n        **Configuration via Query Parameters:**\n\n        You can pass ANY watch configuration field as query parameters to apply settings to all imported watches.\n        All parameters from the Watch schema are supported (processor, fetch_backend, notification_urls, etc.).\n\n        **Special Parameters:**\n        - `tag` / `tag_uuids` - Assign tags to imported watches\n        - `proxy` - Use specific proxy for imported watches\n        - `dedupe` - Skip duplicate URLs (default: true)\n\n        **Type Conversion:**\n        - Booleans: `true`, `false`, `1`, `0`, `yes`, `no`\n        - Arrays: Comma-separated or JSON format (`[item1,item2]`)\n        - Objects: JSON format (`{\"key\":\"value\"}`)\n        - Numbers: Parsed as int or float\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            # Basic import\n            curl -X POST \"http://localhost:5000/api/v1/import\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: text/plain\" \\\n              -d $'https://example.com\\nhttps://example.org\\nhttps://example.net'\n\n            # Import with processor and fetch backend\n            curl -X POST \"http://localhost:5000/api/v1/import?processor=restock_diff&fetch_backend=html_webdriver\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: text/plain\" \\\n              -d $'https://example.com\\nhttps://example.org'\n\n            # Import with multiple settings\n            curl -X POST \"http://localhost:5000/api/v1/import?processor=restock_diff&paused=true&tag=production\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: text/plain\" \\\n              -d $'https://example.com'\n        - lang: 'Python'\n          source: |\n            import requests\n\n            headers = {\n                'x-api-key': 'YOUR_API_KEY',\n                'Content-Type': 'text/plain'\n            }\n\n            # Basic import\n            urls = 'https://example.com\\nhttps://example.org\\nhttps://example.net'\n            response = requests.post('http://localhost:5000/api/v1/import',\n                                   headers=headers, data=urls)\n            print(response.json())\n\n            # Import with configuration\n            params = {\n                'processor': 'restock_diff',\n                'fetch_backend': 'html_webdriver',\n                'paused': 'false',\n                'tag': 'production'\n            }\n            response = requests.post('http://localhost:5000/api/v1/import',\n                                   headers=headers, params=params, data=urls)\n            print(response.json())\n      parameters:\n        - name: tag_uuids\n          in: query\n          description: Tag UUID(s) to apply to imported watches (comma-separated for multiple)\n          schema:\n            type: string\n          example: \"550e8400-e29b-41d4-a716-446655440000\"\n        - name: tag\n          in: query\n          description: Tag name to apply to imported watches\n          schema:\n            type: string\n          example: \"production\"\n        - name: proxy\n          in: query\n          description: Proxy key to use for imported watches\n          schema:\n            type: string\n          example: \"proxy1\"\n        - name: dedupe\n          in: query\n          description: Skip duplicate URLs (default true)\n          schema:\n            type: boolean\n            default: true\n      requestBody:\n        required: true\n        content:\n          text/plain:\n            schema:\n              type: string\n            example: |\n              https://example.com\n              https://example.org\n              https://example.net\n      responses:\n        '200':\n          description: URLs imported successfully\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  type: string\n                  format: uuid\n                description: List of created watch UUIDs\n        '500':\n          description: Server error\n\n  /systeminfo:\n    get:\n      operationId: getSystemInfo\n      tags: [System Information]\n      summary: Get system information\n      description: Return information about the current system state\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            curl -X GET \"http://localhost:5000/api/v1/systeminfo\" \\\n              -H \"x-api-key: YOUR_API_KEY\"\n        - lang: 'Python'\n          source: |\n            import requests\n\n            headers = {'x-api-key': 'YOUR_API_KEY'}\n            response = requests.get('http://localhost:5000/api/v1/systeminfo', headers=headers)\n            print(response.json())\n      responses:\n        '200':\n          description: System information\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/SystemInfo'\n              example:\n                watch_count: 42\n                tag_count: 5\n                uptime: \"2 days, 3:45:12\"\n                version: \"0.50.10\"\n\n  /full-spec:\n    get:\n      operationId: getFullApiSpec\n      tags: [Plugin API Extensions]\n      summary: Get full live API spec\n      description: |\n        Return the fully merged OpenAPI specification for this instance.\n\n        Unlike the static `api-spec.yaml` shipped with the application, this endpoint returns the\n        spec dynamically merged with any `api.yaml` schemas provided by installed processor plugins.\n\n        **Use this URL** with Swagger UI or Redoc to get schema-accurate documentation for your\n        specific install — it includes every `processor_config_<name>` schema block contributed by\n        installed processors (e.g. `processor_config_restock_diff` from the built-in restock plugin).\n\n        This endpoint requires no authentication and returns YAML.\n\n        To load it directly in Swagger UI, paste the URL into the \"Explore\" box:\n        ```\n        http://localhost:5000/api/v1/full-spec\n        ```\n      security: []\n      x-code-samples:\n        - lang: 'curl'\n          source: |\n            # Fetch the live merged spec (no API key needed)\n            curl -X GET \"http://localhost:5000/api/v1/full-spec\"\n        - lang: 'Python'\n          source: |\n            import requests\n\n            # No authentication required\n            response = requests.get('http://localhost:5000/api/v1/full-spec')\n            print(response.text)  # Returns YAML\n      responses:\n        '200':\n          description: |\n            Merged OpenAPI specification in YAML format. Includes all processor plugin schemas\n            (e.g. `processor_config_restock_diff`) not present in the static `api-spec.yaml`.\n          content:\n            application/yaml:\n              schema:\n                type: string\n"
  },
  {
    "path": "docs/api_v1/index.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf8\" />\n  <title>ChangeDetection.io API</title>\n  <!-- needed for adaptive design -->\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <style>\n    body {\n      padding: 0;\n      margin: 0;\n    }\n  </style>\n  <script src=\"https://cdn.redocly.com/redoc/v2.5.0/bundles/redoc.standalone.js\"></script><style data-styled=\"true\" data-styled-version=\"6.1.19\">.fqkwbU{width:calc(100% - 40%);padding:0 40px;}/*!sc*/\n@media print,screen and (max-width: 75rem){.fqkwbU{width:100%;padding:40px 40px;}}/*!sc*/\n.dCzIPc{width:calc(100% - 40%);padding:0 40px;}/*!sc*/\n@media print,screen and (max-width: 75rem){.dCzIPc{width:100%;padding:0px 40px;}}/*!sc*/\ndata-styled.g4[id=\"sc-ggWZvA\"]{content:\"fqkwbU,dCzIPc,\"}/*!sc*/\n.bPmFpz{padding:40px 0;}/*!sc*/\n.bPmFpz:last-child{min-height:calc(100vh + 1px);}/*!sc*/\n.bPmFpz>.bPmFpz:last-child{min-height:initial;}/*!sc*/\n@media print,screen and (max-width: 75rem){.bPmFpz{padding:0;}}/*!sc*/\n.gHrCVQ{padding:40px 0;position:relative;}/*!sc*/\n.gHrCVQ:last-child{min-height:calc(100vh + 1px);}/*!sc*/\n.gHrCVQ>.gHrCVQ:last-child{min-height:initial;}/*!sc*/\n@media print,screen and (max-width: 75rem){.gHrCVQ{padding:0;}}/*!sc*/\n.gHrCVQ:not(:last-of-type):after{position:absolute;bottom:0;width:100%;display:block;content:'';border-bottom:1px solid rgba(0, 0, 0, 0.2);}/*!sc*/\ndata-styled.g5[id=\"sc-dTvVRJ\"]{content:\"bPmFpz,gHrCVQ,\"}/*!sc*/\n.bDYKKx{width:40%;color:#ffffff;background-color:#263238;padding:0 40px;}/*!sc*/\n@media print,screen and (max-width: 75rem){.bDYKKx{width:100%;padding:40px 40px;}}/*!sc*/\ndata-styled.g6[id=\"sc-jwTyAe\"]{content:\"bDYKKx,\"}/*!sc*/\n.FFPsr{background-color:#263238;}/*!sc*/\ndata-styled.g7[id=\"sc-hjsuWn\"]{content:\"FFPsr,\"}/*!sc*/\n.gkiSyE{display:flex;width:100%;padding:0;}/*!sc*/\n@media print,screen and (max-width: 75rem){.gkiSyE{flex-direction:column;}}/*!sc*/\ndata-styled.g8[id=\"sc-jJLAfE\"]{content:\"gkiSyE,\"}/*!sc*/\n.wYHiz{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.85714em;line-height:1.6em;color:#333333;}/*!sc*/\ndata-styled.g9[id=\"sc-hwkwBN\"]{content:\"wYHiz,\"}/*!sc*/\n.iFSqkw{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.57143em;line-height:1.6em;color:#333333;margin:0 0 20px;}/*!sc*/\ndata-styled.g10[id=\"sc-kNOymR\"]{content:\"iFSqkw,\"}/*!sc*/\n.cXqSZD{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.27em;line-height:1.6em;color:#333333;}/*!sc*/\ndata-styled.g11[id=\"sc-dYwGCk\"]{content:\"cXqSZD,\"}/*!sc*/\n.drJHMo{color:#ffffff;}/*!sc*/\ndata-styled.g12[id=\"sc-lgpSej\"]{content:\"drJHMo,\"}/*!sc*/\n.czjApA{border-bottom:1px solid rgba(38, 50, 56, 0.3);margin:1em 0 1em 0;color:rgba(38, 50, 56, 0.5);font-weight:normal;text-transform:uppercase;font-size:0.929em;line-height:20px;}/*!sc*/\ndata-styled.g13[id=\"sc-eqYatC\"]{content:\"czjApA,\"}/*!sc*/\n.fRdsOi{cursor:pointer;margin-left:-20px;padding:0;line-height:1;width:20px;display:inline-block;outline:0;}/*!sc*/\n.fRdsOi:before{content:'';width:15px;height:15px;background-size:contain;background-image:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgeD0iMCIgeT0iMCIgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MTIgNTEyIiB4bWw6c3BhY2U9InByZXNlcnZlIj48cGF0aCBmaWxsPSIjMDEwMTAxIiBkPSJNNDU5LjcgMjMzLjRsLTkwLjUgOTAuNWMtNTAgNTAtMTMxIDUwLTE4MSAwIC03LjktNy44LTE0LTE2LjctMTkuNC0yNS44bDQyLjEtNDIuMWMyLTIgNC41LTMuMiA2LjgtNC41IDIuOSA5LjkgOCAxOS4zIDE1LjggMjcuMiAyNSAyNSA2NS42IDI0LjkgOTAuNSAwbDkwLjUtOTAuNWMyNS0yNSAyNS02NS42IDAtOTAuNSAtMjQuOS0yNS02NS41LTI1LTkwLjUgMGwtMzIuMiAzMi4yYy0yNi4xLTEwLjItNTQuMi0xMi45LTgxLjYtOC45bDY4LjYtNjguNmM1MC01MCAxMzEtNTAgMTgxIDBDNTA5LjYgMTAyLjMgNTA5LjYgMTgzLjQgNDU5LjcgMjMzLjR6TTIyMC4zIDM4Mi4ybC0zMi4yIDMyLjJjLTI1IDI0LjktNjUuNiAyNC45LTkwLjUgMCAtMjUtMjUtMjUtNjUuNiAwLTkwLjVsOTAuNS05MC41YzI1LTI1IDY1LjUtMjUgOTAuNSAwIDcuOCA3LjggMTIuOSAxNy4yIDE1LjggMjcuMSAyLjQtMS40IDQuOC0yLjUgNi44LTQuNWw0Mi4xLTQyYy01LjQtOS4yLTExLjYtMTgtMTkuNC0yNS44IC01MC01MC0xMzEtNTAtMTgxIDBsLTkwLjUgOTAuNWMtNTAgNTAtNTAgMTMxIDAgMTgxIDUwIDUwIDEzMSA1MCAxODEgMGw2OC42LTY4LjZDMjc0LjYgMzk1LjEgMjQ2LjQgMzkyLjMgMjIwLjMgMzgyLjJ6Ii8+PC9zdmc+Cg==');opacity:0.5;visibility:hidden;display:inline-block;vertical-align:middle;}/*!sc*/\nh1:hover>.fRdsOi::before,h2:hover>.fRdsOi::before,.fRdsOi:hover::before{visibility:visible;}/*!sc*/\ndata-styled.g14[id=\"sc-kcLKEh\"]{content:\"fRdsOi,\"}/*!sc*/\n.dUlzCe{height:18px;width:18px;min-width:18px;vertical-align:middle;float:right;transition:transform 0.2s ease-out;transform:rotateZ(-90deg);}/*!sc*/\n.FtowP{height:1.3em;width:1.3em;min-width:1.3em;vertical-align:middle;transition:transform 0.2s ease-out;transform:rotateZ(-90deg);}/*!sc*/\n.cGxVlA{height:1.5em;width:1.5em;min-width:1.5em;vertical-align:middle;float:left;transition:transform 0.2s ease-out;transform:rotateZ(-90deg);}/*!sc*/\n.cGxVlA polygon{fill:#1d8127;}/*!sc*/\n.iuNpUs{height:20px;width:20px;min-width:20px;vertical-align:middle;float:right;transition:transform 0.2s ease-out;transform:rotateZ(0);}/*!sc*/\n.iuNpUs polygon{fill:white;}/*!sc*/\n.dOPmTa{height:18px;width:18px;min-width:18px;vertical-align:middle;transition:transform 0.2s ease-out;transform:rotateZ(-90deg);}/*!sc*/\n.jKYZgc{height:1.5em;width:1.5em;min-width:1.5em;vertical-align:middle;float:left;transition:transform 0.2s ease-out;transform:rotateZ(-90deg);}/*!sc*/\n.jKYZgc polygon{fill:#d41f1c;}/*!sc*/\ndata-styled.g15[id=\"sc-dntSTA\"]{content:\"dUlzCe,FtowP,cGxVlA,iuNpUs,dOPmTa,jKYZgc,\"}/*!sc*/\n.gdmNWp{border-left:1px solid #7c7cbb;box-sizing:border-box;position:relative;padding:10px 10px 10px 0;}/*!sc*/\n@media screen and (max-width: 50rem){.gdmNWp{display:block;overflow:hidden;}}/*!sc*/\ntr:first-of-type>.gdmNWp,tr.last>.gdmNWp{border-left-width:0;background-position:top left;background-repeat:no-repeat;background-size:1px 100%;}/*!sc*/\ntr:first-of-type>.gdmNWp{background-image:linear-gradient(\n      to bottom,\n      transparent 0%,\n      transparent 22px,\n      #7c7cbb 22px,\n      #7c7cbb 100%\n    );}/*!sc*/\ntr.last>.gdmNWp{background-image:linear-gradient(\n      to bottom,\n      #7c7cbb 0%,\n      #7c7cbb 22px,\n      transparent 22px,\n      transparent 100%\n    );}/*!sc*/\ntr.last+tr>.gdmNWp{border-left-color:transparent;}/*!sc*/\ntr.last:first-child>.gdmNWp{background:none;border-left-color:transparent;}/*!sc*/\ndata-styled.g18[id=\"sc-kCuUfV\"]{content:\"gdmNWp,\"}/*!sc*/\n.dFOJWJ{vertical-align:top;line-height:20px;white-space:nowrap;font-size:13px;font-family:Courier,monospace;}/*!sc*/\n.dFOJWJ.deprecated{text-decoration:line-through;color:#707070;}/*!sc*/\ndata-styled.g20[id=\"sc-fbQrwq\"]{content:\"dFOJWJ,\"}/*!sc*/\n.ixGaBD{border-bottom:1px solid #9fb4be;padding:10px 0;width:75%;box-sizing:border-box;}/*!sc*/\ntr.expanded .ixGaBD{border-bottom:none;}/*!sc*/\n@media screen and (max-width: 50rem){.ixGaBD{padding:0 20px;border-bottom:none;border-left:1px solid #7c7cbb;}tr.last>.ixGaBD{border-left:none;}}/*!sc*/\ndata-styled.g21[id=\"sc-gGKoUb\"]{content:\"ixGaBD,\"}/*!sc*/\n.cteAyA{color:#7c7cbb;font-family:Courier,monospace;margin-right:10px;}/*!sc*/\n.cteAyA::before{content:'';display:inline-block;vertical-align:middle;width:10px;height:1px;background:#7c7cbb;}/*!sc*/\n.cteAyA::after{content:'';display:inline-block;vertical-align:middle;width:1px;background:#7c7cbb;height:7px;}/*!sc*/\ndata-styled.g22[id=\"sc-hwddKA\"]{content:\"cteAyA,\"}/*!sc*/\n.icJLQx{border-collapse:separate;border-radius:3px;font-size:14px;border-spacing:0;width:100%;}/*!sc*/\n.icJLQx >tr{vertical-align:middle;}/*!sc*/\n@media screen and (max-width: 50rem){.icJLQx{display:block;}.icJLQx >tr,.icJLQx >tbody>tr{display:block;}}/*!sc*/\n@media screen and (max-width: 50rem) and (-ms-high-contrast:none){.icJLQx td{float:left;width:100%;}}/*!sc*/\n.icJLQx .sc-jaXbil,.icJLQx .sc-jaXbil .sc-jaXbil .sc-jaXbil,.icJLQx .sc-jaXbil .sc-jaXbil .sc-jaXbil .sc-jaXbil .sc-jaXbil{margin:1em;margin-right:0;background:#fafafa;}/*!sc*/\n.icJLQx .sc-jaXbil .sc-jaXbil,.icJLQx .sc-jaXbil .sc-jaXbil .sc-jaXbil .sc-jaXbil,.icJLQx .sc-jaXbil .sc-jaXbil .sc-jaXbil .sc-jaXbil .sc-jaXbil .sc-jaXbil{background:#ffffff;}/*!sc*/\ndata-styled.g24[id=\"sc-eqNDNG\"]{content:\"icJLQx,\"}/*!sc*/\n.fyxuKi >ul{list-style:none;padding:0;margin:0;margin:0 -5px;}/*!sc*/\n.fyxuKi >ul >li{padding:5px 10px;display:inline-block;background-color:#11171a;border-bottom:1px solid rgba(0, 0, 0, 0.5);cursor:pointer;text-align:center;outline:none;color:#ccc;margin:0 5px 5px 5px;border:1px solid #07090b;border-radius:5px;min-width:60px;font-size:0.9em;font-weight:bold;}/*!sc*/\n.fyxuKi >ul >li.react-tabs__tab--selected{color:#333333;background:#ffffff;}/*!sc*/\n.fyxuKi >ul >li.react-tabs__tab--selected:focus{outline:auto;}/*!sc*/\n.fyxuKi >ul >li:only-child{flex:none;min-width:100px;}/*!sc*/\n.fyxuKi >ul >li.tab-success{color:#1d8127;}/*!sc*/\n.fyxuKi >ul >li.tab-redirect{color:#ffa500;}/*!sc*/\n.fyxuKi >ul >li.tab-info{color:#87ceeb;}/*!sc*/\n.fyxuKi >ul >li.tab-error{color:#d41f1c;}/*!sc*/\n.fyxuKi >.react-tabs__tab-panel{background:#11171a;}/*!sc*/\n.fyxuKi >.react-tabs__tab-panel>div,.fyxuKi >.react-tabs__tab-panel>pre{padding:20px;margin:0;}/*!sc*/\n.fyxuKi >.react-tabs__tab-panel>div>pre{padding:0;}/*!sc*/\ndata-styled.g30[id=\"sc-cOpnSz\"]{content:\"fyxuKi,\"}/*!sc*/\n.kIppRw code[class*='language-'],.kIppRw pre[class*='language-']{text-shadow:0 -0.1em 0.2em black;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none;}/*!sc*/\n@media print{.kIppRw code[class*='language-'],.kIppRw pre[class*='language-']{text-shadow:none;}}/*!sc*/\n.kIppRw pre[class*='language-']{padding:1em;margin:0.5em 0;overflow:auto;}/*!sc*/\n.kIppRw .token.comment,.kIppRw .token.prolog,.kIppRw .token.doctype,.kIppRw .token.cdata{color:hsl(30, 20%, 50%);}/*!sc*/\n.kIppRw .token.punctuation{opacity:0.7;}/*!sc*/\n.kIppRw .namespace{opacity:0.7;}/*!sc*/\n.kIppRw .token.property,.kIppRw .token.tag,.kIppRw .token.number,.kIppRw .token.constant,.kIppRw .token.symbol{color:#4a8bb3;}/*!sc*/\n.kIppRw .token.boolean{color:#e64441;}/*!sc*/\n.kIppRw .token.selector,.kIppRw .token.attr-name,.kIppRw .token.string,.kIppRw .token.char,.kIppRw .token.builtin,.kIppRw .token.inserted{color:#a0fbaa;}/*!sc*/\n.kIppRw .token.selector+a,.kIppRw .token.attr-name+a,.kIppRw .token.string+a,.kIppRw .token.char+a,.kIppRw .token.builtin+a,.kIppRw .token.inserted+a,.kIppRw .token.selector+a:visited,.kIppRw .token.attr-name+a:visited,.kIppRw .token.string+a:visited,.kIppRw .token.char+a:visited,.kIppRw .token.builtin+a:visited,.kIppRw .token.inserted+a:visited{color:#4ed2ba;text-decoration:underline;}/*!sc*/\n.kIppRw .token.property.string{color:white;}/*!sc*/\n.kIppRw .token.operator,.kIppRw .token.entity,.kIppRw .token.url,.kIppRw .token.variable{color:hsl(40, 90%, 60%);}/*!sc*/\n.kIppRw .token.atrule,.kIppRw .token.attr-value,.kIppRw .token.keyword{color:hsl(350, 40%, 70%);}/*!sc*/\n.kIppRw .token.regex,.kIppRw .token.important{color:#e90;}/*!sc*/\n.kIppRw .token.important,.kIppRw .token.bold{font-weight:bold;}/*!sc*/\n.kIppRw .token.italic{font-style:italic;}/*!sc*/\n.kIppRw .token.entity{cursor:help;}/*!sc*/\n.kIppRw .token.deleted{color:red;}/*!sc*/\ndata-styled.g32[id=\"sc-eVqvcJ\"]{content:\"kIppRw,\"}/*!sc*/\n.bBWkcI{opacity:0.7;transition:opacity 0.3s ease;text-align:right;}/*!sc*/\n.bBWkcI:focus-within{opacity:1;}/*!sc*/\n.bBWkcI >button{background-color:transparent;border:0;color:inherit;padding:2px 10px;font-family:Roboto,sans-serif;font-size:14px;line-height:1.5em;cursor:pointer;outline:0;}/*!sc*/\n.bBWkcI >button :hover,.bBWkcI >button :focus{background:rgba(255, 255, 255, 0.1);}/*!sc*/\ndata-styled.g33[id=\"sc-bbbBoY\"]{content:\"bBWkcI,\"}/*!sc*/\n.gsEOpk:hover .sc-bbbBoY{opacity:1;}/*!sc*/\ndata-styled.g34[id=\"sc-cdmAjP\"]{content:\"gsEOpk,\"}/*!sc*/\n.cCzeOT{font-family:Courier,monospace;font-size:13px;overflow-x:auto;margin:0;white-space:pre;}/*!sc*/\ndata-styled.g35[id=\"sc-jytpVa\"]{content:\"cCzeOT,\"}/*!sc*/\n.ghzOpX{position:relative;}/*!sc*/\ndata-styled.g37[id=\"sc-eknHtZ\"]{content:\"ghzOpX,\"}/*!sc*/\n.eyTvTk{position:absolute;pointer-events:none;z-index:1;top:50%;-webkit-transform:translateY(-50%);-ms-transform:translateY(-50%);transform:translateY(-50%);right:8px;margin:auto;text-align:center;}/*!sc*/\n.eyTvTk polyline{color:white;}/*!sc*/\ndata-styled.g38[id=\"sc-pYNGo\"]{content:\"eyTvTk,\"}/*!sc*/\n.dbfEBv{box-sizing:border-box;min-width:100px;outline:none;display:inline-block;border-radius:2px;border:1px solid rgba(38, 50, 56, 0.5);vertical-align:bottom;padding:2px 0px 2px 6px;position:relative;width:auto;background:white;color:#263238;font-family:Montserrat,sans-serif;font-size:0.929em;line-height:1.5em;cursor:pointer;transition:border 0.25s ease,color 0.25s ease,box-shadow 0.25s ease;}/*!sc*/\n.dbfEBv label{box-sizing:border-box;min-width:100px;outline:none;display:inline-block;font-family:Montserrat,sans-serif;color:#333333;vertical-align:bottom;width:auto;text-transform:none;padding:0 22px 0 4px;font-size:0.929em;line-height:1.5em;font-family:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;}/*!sc*/\n.dbfEBv .dropdown-select{position:absolute;top:0;left:0;width:100%;height:100%;opacity:0;border:none;appearance:none;cursor:pointer;color:#333333;line-height:inherit;font-family:inherit;}/*!sc*/\n.dbfEBv:hover,.dbfEBv:focus-within{border:1px solid #32329f;color:#32329f;box-shadow:0px 0px 0px 1px #32329f;}/*!sc*/\ndata-styled.g39[id=\"sc-cCVJLD\"]{content:\"dbfEBv,\"}/*!sc*/\n.cFlAeY{margin-left:10px;text-transform:none;font-size:0.929em;color:black;}/*!sc*/\ndata-styled.g41[id=\"sc-dNFkOE\"]{content:\"cFlAeY,\"}/*!sc*/\n.kbZred{font-family:Roboto,sans-serif;font-weight:400;line-height:1.5em;}/*!sc*/\n.kbZred p:last-child{margin-bottom:0;}/*!sc*/\n.kbZred h1{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.85714em;line-height:1.6em;color:#32329f;margin-top:0;}/*!sc*/\n.kbZred h2{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.57143em;line-height:1.6em;color:#333333;}/*!sc*/\n.kbZred code{color:#e53935;background-color:rgba(38, 50, 56, 0.05);font-family:Courier,monospace;border-radius:2px;border:1px solid rgba(38, 50, 56, 0.1);padding:0 5px;font-size:13px;font-weight:400;word-break:break-word;}/*!sc*/\n.kbZred pre{font-family:Courier,monospace;white-space:pre;background-color:#11171a;color:white;padding:20px;overflow-x:auto;line-height:normal;border-radius:0;border:1px solid rgba(38, 50, 56, 0.1);}/*!sc*/\n.kbZred pre code{background-color:transparent;color:white;padding:0;}/*!sc*/\n.kbZred pre code:before,.kbZred pre code:after{content:none;}/*!sc*/\n.kbZred blockquote{margin:0;margin-bottom:1em;padding:0 15px;color:#777;border-left:4px solid #ddd;}/*!sc*/\n.kbZred img{max-width:100%;box-sizing:content-box;}/*!sc*/\n.kbZred ul,.kbZred ol{padding-left:2em;margin:0;margin-bottom:1em;}/*!sc*/\n.kbZred ul ul,.kbZred ol ul,.kbZred ul ol,.kbZred ol ol{margin-bottom:0;margin-top:0;}/*!sc*/\n.kbZred table{display:block;width:100%;overflow:auto;word-break:normal;word-break:keep-all;border-collapse:collapse;border-spacing:0;margin-top:1.5em;margin-bottom:1.5em;}/*!sc*/\n.kbZred table tr{background-color:#fff;border-top:1px solid #ccc;}/*!sc*/\n.kbZred table tr:nth-child(2n){background-color:#fafafa;}/*!sc*/\n.kbZred table th,.kbZred table td{padding:6px 13px;border:1px solid #ddd;}/*!sc*/\n.kbZred table th{text-align:left;font-weight:bold;}/*!sc*/\n.kbZred .share-link{cursor:pointer;margin-left:-20px;padding:0;line-height:1;width:20px;display:inline-block;outline:0;}/*!sc*/\n.kbZred .share-link:before{content:'';width:15px;height:15px;background-size:contain;background-image:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgeD0iMCIgeT0iMCIgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MTIgNTEyIiB4bWw6c3BhY2U9InByZXNlcnZlIj48cGF0aCBmaWxsPSIjMDEwMTAxIiBkPSJNNDU5LjcgMjMzLjRsLTkwLjUgOTAuNWMtNTAgNTAtMTMxIDUwLTE4MSAwIC03LjktNy44LTE0LTE2LjctMTkuNC0yNS44bDQyLjEtNDIuMWMyLTIgNC41LTMuMiA2LjgtNC41IDIuOSA5LjkgOCAxOS4zIDE1LjggMjcuMiAyNSAyNSA2NS42IDI0LjkgOTAuNSAwbDkwLjUtOTAuNWMyNS0yNSAyNS02NS42IDAtOTAuNSAtMjQuOS0yNS02NS41LTI1LTkwLjUgMGwtMzIuMiAzMi4yYy0yNi4xLTEwLjItNTQuMi0xMi45LTgxLjYtOC45bDY4LjYtNjguNmM1MC01MCAxMzEtNTAgMTgxIDBDNTA5LjYgMTAyLjMgNTA5LjYgMTgzLjQgNDU5LjcgMjMzLjR6TTIyMC4zIDM4Mi4ybC0zMi4yIDMyLjJjLTI1IDI0LjktNjUuNiAyNC45LTkwLjUgMCAtMjUtMjUtMjUtNjUuNiAwLTkwLjVsOTAuNS05MC41YzI1LTI1IDY1LjUtMjUgOTAuNSAwIDcuOCA3LjggMTIuOSAxNy4yIDE1LjggMjcuMSAyLjQtMS40IDQuOC0yLjUgNi44LTQuNWw0Mi4xLTQyYy01LjQtOS4yLTExLjYtMTgtMTkuNC0yNS44IC01MC01MC0xMzEtNTAtMTgxIDBsLTkwLjUgOTAuNWMtNTAgNTAtNTAgMTMxIDAgMTgxIDUwIDUwIDEzMSA1MCAxODEgMGw2OC42LTY4LjZDMjc0LjYgMzk1LjEgMjQ2LjQgMzkyLjMgMjIwLjMgMzgyLjJ6Ii8+PC9zdmc+Cg==');opacity:0.5;visibility:hidden;display:inline-block;vertical-align:middle;}/*!sc*/\n.kbZred h1:hover>.share-link::before,.kbZred h2:hover>.share-link::before,.kbZred .share-link:hover::before{visibility:visible;}/*!sc*/\n.kbZred a{text-decoration:auto;color:#32329f;}/*!sc*/\n.kbZred a:visited{color:#32329f;}/*!sc*/\n.kbZred a:hover{color:#6868cf;text-decoration:auto;}/*!sc*/\n.drqpJr{font-family:Roboto,sans-serif;font-weight:400;line-height:1.5em;}/*!sc*/\n.drqpJr p:last-child{margin-bottom:0;}/*!sc*/\n.drqpJr p:first-child{margin-top:0;}/*!sc*/\n.drqpJr p:last-child{margin-bottom:0;}/*!sc*/\n.drqpJr h1{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.85714em;line-height:1.6em;color:#32329f;margin-top:0;}/*!sc*/\n.drqpJr h2{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.57143em;line-height:1.6em;color:#333333;}/*!sc*/\n.drqpJr code{color:#e53935;background-color:rgba(38, 50, 56, 0.05);font-family:Courier,monospace;border-radius:2px;border:1px solid rgba(38, 50, 56, 0.1);padding:0 5px;font-size:13px;font-weight:400;word-break:break-word;}/*!sc*/\n.drqpJr pre{font-family:Courier,monospace;white-space:pre;background-color:#11171a;color:white;padding:20px;overflow-x:auto;line-height:normal;border-radius:0;border:1px solid rgba(38, 50, 56, 0.1);}/*!sc*/\n.drqpJr pre code{background-color:transparent;color:white;padding:0;}/*!sc*/\n.drqpJr pre code:before,.drqpJr pre code:after{content:none;}/*!sc*/\n.drqpJr blockquote{margin:0;margin-bottom:1em;padding:0 15px;color:#777;border-left:4px solid #ddd;}/*!sc*/\n.drqpJr img{max-width:100%;box-sizing:content-box;}/*!sc*/\n.drqpJr ul,.drqpJr ol{padding-left:2em;margin:0;margin-bottom:1em;}/*!sc*/\n.drqpJr ul ul,.drqpJr ol ul,.drqpJr ul ol,.drqpJr ol ol{margin-bottom:0;margin-top:0;}/*!sc*/\n.drqpJr table{display:block;width:100%;overflow:auto;word-break:normal;word-break:keep-all;border-collapse:collapse;border-spacing:0;margin-top:1.5em;margin-bottom:1.5em;}/*!sc*/\n.drqpJr table tr{background-color:#fff;border-top:1px solid #ccc;}/*!sc*/\n.drqpJr table tr:nth-child(2n){background-color:#fafafa;}/*!sc*/\n.drqpJr table th,.drqpJr table td{padding:6px 13px;border:1px solid #ddd;}/*!sc*/\n.drqpJr table th{text-align:left;font-weight:bold;}/*!sc*/\n.drqpJr .share-link{cursor:pointer;margin-left:-20px;padding:0;line-height:1;width:20px;display:inline-block;outline:0;}/*!sc*/\n.drqpJr .share-link:before{content:'';width:15px;height:15px;background-size:contain;background-image:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgeD0iMCIgeT0iMCIgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MTIgNTEyIiB4bWw6c3BhY2U9InByZXNlcnZlIj48cGF0aCBmaWxsPSIjMDEwMTAxIiBkPSJNNDU5LjcgMjMzLjRsLTkwLjUgOTAuNWMtNTAgNTAtMTMxIDUwLTE4MSAwIC03LjktNy44LTE0LTE2LjctMTkuNC0yNS44bDQyLjEtNDIuMWMyLTIgNC41LTMuMiA2LjgtNC41IDIuOSA5LjkgOCAxOS4zIDE1LjggMjcuMiAyNSAyNSA2NS42IDI0LjkgOTAuNSAwbDkwLjUtOTAuNWMyNS0yNSAyNS02NS42IDAtOTAuNSAtMjQuOS0yNS02NS41LTI1LTkwLjUgMGwtMzIuMiAzMi4yYy0yNi4xLTEwLjItNTQuMi0xMi45LTgxLjYtOC45bDY4LjYtNjguNmM1MC01MCAxMzEtNTAgMTgxIDBDNTA5LjYgMTAyLjMgNTA5LjYgMTgzLjQgNDU5LjcgMjMzLjR6TTIyMC4zIDM4Mi4ybC0zMi4yIDMyLjJjLTI1IDI0LjktNjUuNiAyNC45LTkwLjUgMCAtMjUtMjUtMjUtNjUuNiAwLTkwLjVsOTAuNS05MC41YzI1LTI1IDY1LjUtMjUgOTAuNSAwIDcuOCA3LjggMTIuOSAxNy4yIDE1LjggMjcuMSAyLjQtMS40IDQuOC0yLjUgNi44LTQuNWw0Mi4xLTQyYy01LjQtOS4yLTExLjYtMTgtMTkuNC0yNS44IC01MC01MC0xMzEtNTAtMTgxIDBsLTkwLjUgOTAuNWMtNTAgNTAtNTAgMTMxIDAgMTgxIDUwIDUwIDEzMSA1MCAxODEgMGw2OC42LTY4LjZDMjc0LjYgMzk1LjEgMjQ2LjQgMzkyLjMgMjIwLjMgMzgyLjJ6Ii8+PC9zdmc+Cg==');opacity:0.5;visibility:hidden;display:inline-block;vertical-align:middle;}/*!sc*/\n.drqpJr h1:hover>.share-link::before,.drqpJr h2:hover>.share-link::before,.drqpJr .share-link:hover::before{visibility:visible;}/*!sc*/\n.drqpJr a{text-decoration:auto;color:#32329f;}/*!sc*/\n.drqpJr a:visited{color:#32329f;}/*!sc*/\n.drqpJr a:hover{color:#6868cf;text-decoration:auto;}/*!sc*/\n.jnwENr{font-family:Roboto,sans-serif;font-weight:400;line-height:1.5em;}/*!sc*/\n.jnwENr p:last-child{margin-bottom:0;}/*!sc*/\n.jnwENr p:first-child{margin-top:0;}/*!sc*/\n.jnwENr p:last-child{margin-bottom:0;}/*!sc*/\n.jnwENr p{display:inline-block;}/*!sc*/\n.jnwENr h1{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.85714em;line-height:1.6em;color:#32329f;margin-top:0;}/*!sc*/\n.jnwENr h2{font-family:Montserrat,sans-serif;font-weight:400;font-size:1.57143em;line-height:1.6em;color:#333333;}/*!sc*/\n.jnwENr code{color:#e53935;background-color:rgba(38, 50, 56, 0.05);font-family:Courier,monospace;border-radius:2px;border:1px solid rgba(38, 50, 56, 0.1);padding:0 5px;font-size:13px;font-weight:400;word-break:break-word;}/*!sc*/\n.jnwENr pre{font-family:Courier,monospace;white-space:pre;background-color:#11171a;color:white;padding:20px;overflow-x:auto;line-height:normal;border-radius:0;border:1px solid rgba(38, 50, 56, 0.1);}/*!sc*/\n.jnwENr pre code{background-color:transparent;color:white;padding:0;}/*!sc*/\n.jnwENr pre code:before,.jnwENr pre code:after{content:none;}/*!sc*/\n.jnwENr blockquote{margin:0;margin-bottom:1em;padding:0 15px;color:#777;border-left:4px solid #ddd;}/*!sc*/\n.jnwENr img{max-width:100%;box-sizing:content-box;}/*!sc*/\n.jnwENr ul,.jnwENr ol{padding-left:2em;margin:0;margin-bottom:1em;}/*!sc*/\n.jnwENr ul ul,.jnwENr ol ul,.jnwENr ul ol,.jnwENr ol ol{margin-bottom:0;margin-top:0;}/*!sc*/\n.jnwENr table{display:block;width:100%;overflow:auto;word-break:normal;word-break:keep-all;border-collapse:collapse;border-spacing:0;margin-top:1.5em;margin-bottom:1.5em;}/*!sc*/\n.jnwENr table tr{background-color:#fff;border-top:1px solid #ccc;}/*!sc*/\n.jnwENr table tr:nth-child(2n){background-color:#fafafa;}/*!sc*/\n.jnwENr table th,.jnwENr table td{padding:6px 13px;border:1px solid #ddd;}/*!sc*/\n.jnwENr table th{text-align:left;font-weight:bold;}/*!sc*/\n.jnwENr .share-link{cursor:pointer;margin-left:-20px;padding:0;line-height:1;width:20px;display:inline-block;outline:0;}/*!sc*/\n.jnwENr .share-link:before{content:'';width:15px;height:15px;background-size:contain;background-image:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgeD0iMCIgeT0iMCIgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MTIgNTEyIiB4bWw6c3BhY2U9InByZXNlcnZlIj48cGF0aCBmaWxsPSIjMDEwMTAxIiBkPSJNNDU5LjcgMjMzLjRsLTkwLjUgOTAuNWMtNTAgNTAtMTMxIDUwLTE4MSAwIC03LjktNy44LTE0LTE2LjctMTkuNC0yNS44bDQyLjEtNDIuMWMyLTIgNC41LTMuMiA2LjgtNC41IDIuOSA5LjkgOCAxOS4zIDE1LjggMjcuMiAyNSAyNSA2NS42IDI0LjkgOTAuNSAwbDkwLjUtOTAuNWMyNS0yNSAyNS02NS42IDAtOTAuNSAtMjQuOS0yNS02NS41LTI1LTkwLjUgMGwtMzIuMiAzMi4yYy0yNi4xLTEwLjItNTQuMi0xMi45LTgxLjYtOC45bDY4LjYtNjguNmM1MC01MCAxMzEtNTAgMTgxIDBDNTA5LjYgMTAyLjMgNTA5LjYgMTgzLjQgNDU5LjcgMjMzLjR6TTIyMC4zIDM4Mi4ybC0zMi4yIDMyLjJjLTI1IDI0LjktNjUuNiAyNC45LTkwLjUgMCAtMjUtMjUtMjUtNjUuNiAwLTkwLjVsOTAuNS05MC41YzI1LTI1IDY1LjUtMjUgOTAuNSAwIDcuOCA3LjggMTIuOSAxNy4yIDE1LjggMjcuMSAyLjQtMS40IDQuOC0yLjUgNi44LTQuNWw0Mi4xLTQyYy01LjQtOS4yLTExLjYtMTgtMTkuNC0yNS44IC01MC01MC0xMzEtNTAtMTgxIDBsLTkwLjUgOTAuNWMtNTAgNTAtNTAgMTMxIDAgMTgxIDUwIDUwIDEzMSA1MCAxODEgMGw2OC42LTY4LjZDMjc0LjYgMzk1LjEgMjQ2LjQgMzkyLjMgMjIwLjMgMzgyLjJ6Ii8+PC9zdmc+Cg==');opacity:0.5;visibility:hidden;display:inline-block;vertical-align:middle;}/*!sc*/\n.jnwENr h1:hover>.share-link::before,.jnwENr h2:hover>.share-link::before,.jnwENr .share-link:hover::before{visibility:visible;}/*!sc*/\n.jnwENr a{text-decoration:auto;color:#32329f;}/*!sc*/\n.jnwENr a:visited{color:#32329f;}/*!sc*/\n.jnwENr a:hover{color:#6868cf;text-decoration:auto;}/*!sc*/\ndata-styled.g42[id=\"sc-fszimp\"]{content:\"kbZred,drqpJr,jnwENr,\"}/*!sc*/\n.ljKHqG{display:inline;}/*!sc*/\ndata-styled.g43[id=\"sc-etsjJW\"]{content:\"ljKHqG,\"}/*!sc*/\n.iNCOCX{position:relative;}/*!sc*/\ndata-styled.g44[id=\"sc-fYmhhH\"]{content:\"iNCOCX,\"}/*!sc*/\n.fdRrNy:hover>.sc-bbbBoY{opacity:1;}/*!sc*/\ndata-styled.g49[id=\"sc-dClGHI\"]{content:\"fdRrNy,\"}/*!sc*/\n.dFvLDb{font-family:Courier,monospace;font-size:13px;white-space:pre;contain:content;overflow-x:auto;}/*!sc*/\n.dFvLDb .redoc-json code>.collapser{display:none;pointer-events:none;}/*!sc*/\n.dFvLDb .callback-function{color:gray;}/*!sc*/\n.dFvLDb .collapser:after{content:'-';cursor:pointer;}/*!sc*/\n.dFvLDb .collapsed>.collapser:after{content:'+';cursor:pointer;}/*!sc*/\n.dFvLDb .ellipsis:after{content:' … ';}/*!sc*/\n.dFvLDb .collapsible{margin-left:2em;}/*!sc*/\n.dFvLDb .hoverable{padding-top:1px;padding-bottom:1px;padding-left:2px;padding-right:2px;border-radius:2px;}/*!sc*/\n.dFvLDb .hovered{background-color:rgba(235, 238, 249, 1);}/*!sc*/\n.dFvLDb .collapser{background-color:transparent;border:0;color:#fff;font-family:Courier,monospace;font-size:13px;padding-right:6px;padding-left:6px;padding-top:0;padding-bottom:0;display:flex;align-items:center;justify-content:center;width:15px;height:15px;position:absolute;top:4px;left:-1.5em;cursor:default;user-select:none;-webkit-user-select:none;padding:2px;}/*!sc*/\n.dFvLDb .collapser:focus{outline-color:#fff;outline-style:dotted;outline-width:1px;}/*!sc*/\n.dFvLDb ul{list-style-type:none;padding:0px;margin:0px 0px 0px 26px;}/*!sc*/\n.dFvLDb li{position:relative;display:block;}/*!sc*/\n.dFvLDb .hoverable{display:inline-block;}/*!sc*/\n.dFvLDb .selected{outline-style:solid;outline-width:1px;outline-style:dotted;}/*!sc*/\n.dFvLDb .collapsed>.collapsible{display:none;}/*!sc*/\n.dFvLDb .ellipsis{display:none;}/*!sc*/\n.dFvLDb .collapsed>.ellipsis{display:inherit;}/*!sc*/\ndata-styled.g50[id=\"sc-fhfEft\"]{content:\"dFvLDb,\"}/*!sc*/\n.iNRAJK{padding:0.9em;background-color:rgba(38,50,56,0.4);margin:0 0 10px 0;display:block;font-family:Montserrat,sans-serif;font-size:0.929em;line-height:1.5em;}/*!sc*/\ndata-styled.g51[id=\"sc-bAehkN\"]{content:\"iNRAJK,\"}/*!sc*/\n.cXitJ{font-family:Montserrat,sans-serif;font-size:12px;position:absolute;z-index:1;top:-11px;left:12px;font-weight:600;color:rgba(255,255,255,0.7);}/*!sc*/\ndata-styled.g52[id=\"sc-gahYZc\"]{content:\"cXitJ,\"}/*!sc*/\n.iLdyBp{position:relative;}/*!sc*/\ndata-styled.g53[id=\"sc-bSFBcf\"]{content:\"iLdyBp,\"}/*!sc*/\n.ehbHlf{margin:0 0 10px 0;display:block;background-color:rgba(38,50,56,0.4);border:none;padding:0.9em 1.6em 0.9em 0.9em;box-shadow:none;}/*!sc*/\n.ehbHlf label{color:#ffffff;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;font-size:1em;text-transform:none;border:none;}/*!sc*/\n.ehbHlf:hover,.ehbHlf:focus-within{border:none;box-shadow:none;background-color:rgba(38,50,56,0.7);}/*!sc*/\ndata-styled.g54[id=\"sc-gsJsQu\"]{content:\"ehbHlf,\"}/*!sc*/\n.eKKwxo{margin-top:15px;}/*!sc*/\ndata-styled.g56[id=\"sc-blIAwI\"]{content:\"eKKwxo,\"}/*!sc*/\n.kdPQHX.deprecated span.property-name{text-decoration:line-through;color:#707070;}/*!sc*/\n.kdPQHX button{background-color:transparent;border:0;outline:0;font-size:13px;font-family:Courier,monospace;cursor:pointer;padding:0;color:#333333;}/*!sc*/\n.kdPQHX button:focus{font-weight:600;}/*!sc*/\n.kdPQHX .sc-dntSTA{height:1.1em;width:1.1em;}/*!sc*/\n.kdPQHX .sc-dntSTA polygon{fill:#666;}/*!sc*/\ndata-styled.g57[id=\"sc-itBLYH\"]{content:\"kdPQHX,\"}/*!sc*/\n.lhyyLL{vertical-align:middle;font-size:13px;line-height:20px;}/*!sc*/\ndata-styled.g58[id=\"sc-bEjUoa\"]{content:\"lhyyLL,\"}/*!sc*/\n.jYezsP{color:rgba(102,102,102,0.9);}/*!sc*/\ndata-styled.g59[id=\"sc-boKDdR\"]{content:\"jYezsP,\"}/*!sc*/\n.dbKJYq{color:#666;}/*!sc*/\ndata-styled.g60[id=\"sc-fOOuSg\"]{content:\"dbKJYq,\"}/*!sc*/\n.crXmiY{color:#d41f1c;font-size:0.9em;font-weight:normal;margin-left:20px;line-height:1;}/*!sc*/\ndata-styled.g62[id=\"sc-iIvHqT\"]{content:\"crXmiY,\"}/*!sc*/\n.UZcrz{color:#0e7c86;font-family:Courier,monospace;font-size:12px;}/*!sc*/\n.UZcrz::before,.UZcrz::after{content:' ';}/*!sc*/\ndata-styled.g65[id=\"sc-cpclqO\"]{content:\"UZcrz,\"}/*!sc*/\n.kMQdIk{border-radius:2px;word-break:break-word;background-color:rgba(51,51,51,0.05);color:rgba(51,51,51,0.9);padding:0 5px;border:1px solid rgba(51,51,51,0.1);font-family:Courier,monospace;}/*!sc*/\n+{margin-left:0;}/*!sc*/\ndata-styled.g66[id=\"sc-dTWiOz\"]{content:\"kMQdIk,\"}/*!sc*/\n.bDfgbe{border-radius:2px;background-color:rgba(104,104,207,0.05);color:rgba(50,50,159,0.9);margin:0 5px;padding:0 5px;border:1px solid rgba(50,50,159,0.1);}/*!sc*/\n+{margin-left:0;}/*!sc*/\ndata-styled.g68[id=\"sc-goiVcJ\"]{content:\"bDfgbe,\"}/*!sc*/\n.jBrfIx{background-color:transparent;border:0;color:#666;margin-left:5px;border-radius:2px;cursor:pointer;outline-color:#666;font-size:12px;}/*!sc*/\ndata-styled.g69[id=\"sc-gSifMm\"]{content:\"jBrfIx,\"}/*!sc*/\n.eA-DYPM{margin:0 5px;vertical-align:text-top;}/*!sc*/\ndata-styled.g75[id=\"sc-bBhMX\"]{content:\"eA-DYPM,\"}/*!sc*/\n.hRtRoN:after{content:' and ';font-weight:normal;}/*!sc*/\n.hRtRoN:last-child:after{content:none;}/*!sc*/\n.hRtRoN a{text-decoration:auto;color:#32329f;}/*!sc*/\n.hRtRoN a:visited{color:#32329f;}/*!sc*/\n.hRtRoN a:hover{color:#6868cf;text-decoration:auto;}/*!sc*/\ndata-styled.g81[id=\"sc-hqtLyI\"]{content:\"hRtRoN,\"}/*!sc*/\n.gRXavu{white-space:nowrap;}/*!sc*/\n.gRXavu:after{content:' or ';white-space:pre;}/*!sc*/\n.gRXavu:last-child:after,.gRXavu:only-child:after{content:none;}/*!sc*/\n.gRXavu a{text-decoration:auto;color:#32329f;}/*!sc*/\n.gRXavu a:visited{color:#32329f;}/*!sc*/\n.gRXavu a:hover{color:#6868cf;text-decoration:auto;}/*!sc*/\ndata-styled.g82[id=\"sc-iVnIWt\"]{content:\"gRXavu,\"}/*!sc*/\n.dPSGXF{flex:1 1 auto;cursor:pointer;}/*!sc*/\ndata-styled.g83[id=\"sc-hWgKua\"]{content:\"dPSGXF,\"}/*!sc*/\n.fUkQtw{width:75%;text-overflow:ellipsis;border-radius:4px;overflow:hidden;}/*!sc*/\n@media screen and (max-width: 50rem){.fUkQtw{margin-top:10px;}}/*!sc*/\ndata-styled.g84[id=\"sc-jBaHRL\"]{content:\"fUkQtw,\"}/*!sc*/\n.jCoZLr{display:inline-block;margin:0;}/*!sc*/\ndata-styled.g85[id=\"sc-gFqXPY\"]{content:\"jCoZLr,\"}/*!sc*/\n.deUlC{width:100%;display:flex;margin:1em 0;flex-direction:row;}/*!sc*/\n@media screen and (max-width: 50rem){.deUlC{flex-direction:column;}}/*!sc*/\ndata-styled.g86[id=\"sc-ikkVnJ\"]{content:\"deUlC,\"}/*!sc*/\n.hPcPCj{margin-top:0;margin-bottom:0.5em;}/*!sc*/\ndata-styled.g92[id=\"sc-jCWzJg\"]{content:\"hPcPCj,\"}/*!sc*/\n.hijBKj::before{content:'|';display:inline-block;opacity:0.5;width:15px;text-align:center;}/*!sc*/\n.hijBKj:last-child::after{display:none;}/*!sc*/\ndata-styled.g94[id=\"sc-jVxTAy\"]{content:\"hijBKj,\"}/*!sc*/\n.eAqtbt{overflow:hidden;}/*!sc*/\ndata-styled.g95[id=\"sc-erPUmh\"]{content:\"eAqtbt,\"}/*!sc*/\n.beOrEi{display:flex;flex-wrap:wrap;margin-left:-15px;}/*!sc*/\ndata-styled.g96[id=\"sc-iRTMaw\"]{content:\"beOrEi,\"}/*!sc*/\n.NmQLu{width:9ex;display:inline-block;height:13px;line-height:13px;background-color:#333;border-radius:3px;background-repeat:no-repeat;background-position:6px 4px;font-size:7px;font-family:Verdana,sans-serif;color:white;text-transform:uppercase;text-align:center;font-weight:bold;vertical-align:middle;margin-right:6px;margin-top:2px;}/*!sc*/\n.NmQLu.get{background-color:#2F8132;}/*!sc*/\n.NmQLu.post{background-color:#186FAF;}/*!sc*/\n.NmQLu.put{background-color:#95507c;}/*!sc*/\n.NmQLu.options{background-color:#947014;}/*!sc*/\n.NmQLu.patch{background-color:#bf581d;}/*!sc*/\n.NmQLu.delete{background-color:#cc3333;}/*!sc*/\n.NmQLu.basic{background-color:#707070;}/*!sc*/\n.NmQLu.link{background-color:#07818F;}/*!sc*/\n.NmQLu.head{background-color:#A23DAD;}/*!sc*/\n.NmQLu.hook{background-color:#32329f;}/*!sc*/\n.NmQLu.schema{background-color:#707070;}/*!sc*/\ndata-styled.g100[id=\"sc-jxYSNo\"]{content:\"NmQLu,\"}/*!sc*/\n.gAPKXX{margin:0;padding:0;}/*!sc*/\n.gAPKXX:first-child{padding-bottom:32px;}/*!sc*/\n.sc-zOxLx .sc-zOxLx{font-size:0.929em;}/*!sc*/\n.dQnkdy{margin:0;padding:0;display:none;}/*!sc*/\n.dQnkdy:first-child{padding-bottom:32px;}/*!sc*/\n.sc-zOxLx .sc-zOxLx{font-size:0.929em;}/*!sc*/\ndata-styled.g101[id=\"sc-zOxLx\"]{content:\"gAPKXX,dQnkdy,\"}/*!sc*/\n.ixknQI{list-style:none inside none;overflow:hidden;text-overflow:ellipsis;padding:0;}/*!sc*/\ndata-styled.g102[id=\"sc-cgHfjM\"]{content:\"ixknQI,\"}/*!sc*/\n.lgPGwq{cursor:pointer;color:#333333;margin:0;padding:12.5px 20px;display:flex;justify-content:space-between;font-family:Montserrat,sans-serif;font-size:0.929em;text-transform:none;background-color:#fafafa;}/*!sc*/\n.lgPGwq:hover{color:#32329f;background-color:#e1e1e1;}/*!sc*/\n.lgPGwq .sc-dntSTA{height:1.5em;width:1.5em;}/*!sc*/\n.lgPGwq .sc-dntSTA polygon{fill:#333333;}/*!sc*/\n.fTDmgC{cursor:pointer;color:#333333;margin:0;padding:12.5px 20px;padding-left:40px;display:flex;justify-content:space-between;font-family:Montserrat,sans-serif;background-color:#fafafa;}/*!sc*/\n.fTDmgC:hover{color:#32329f;background-color:#ededed;}/*!sc*/\n.fTDmgC .sc-dntSTA{height:1.5em;width:1.5em;}/*!sc*/\n.fTDmgC .sc-dntSTA polygon{fill:#333333;}/*!sc*/\n.iHRgeo{cursor:pointer;color:#333333;margin:0;padding:12.5px 20px;display:flex;justify-content:space-between;font-family:Montserrat,sans-serif;background-color:#fafafa;}/*!sc*/\n.iHRgeo:hover{color:#32329f;background-color:#ededed;}/*!sc*/\n.iHRgeo .sc-dntSTA{height:1.5em;width:1.5em;}/*!sc*/\n.iHRgeo .sc-dntSTA polygon{fill:#333333;}/*!sc*/\ndata-styled.g103[id=\"sc-fpikKz\"]{content:\"lgPGwq,fTDmgC,iHRgeo,\"}/*!sc*/\n.cxcra{display:inline-block;vertical-align:middle;width:calc(100% - 38px);overflow:hidden;text-overflow:ellipsis;}/*!sc*/\ndata-styled.g104[id=\"sc-gWaSiO\"]{content:\"cxcra,\"}/*!sc*/\n.QuyG{font-size:0.8em;margin-top:10px;text-align:center;position:fixed;width:260px;bottom:0;background:#fafafa;}/*!sc*/\n.QuyG a,.QuyG a:visited,.QuyG a:hover{color:#333333!important;padding:5px 0;border-top:1px solid #e1e1e1;text-decoration:none;display:flex;align-items:center;justify-content:center;}/*!sc*/\n.QuyG img{width:15px;margin-right:5px;}/*!sc*/\n@media screen and (max-width: 50rem){.QuyG{width:100%;}}/*!sc*/\ndata-styled.g105[id=\"sc-kSaXSp\"]{content:\"QuyG,\"}/*!sc*/\n.jjnszm{cursor:pointer;position:relative;margin-bottom:5px;}/*!sc*/\ndata-styled.g111[id=\"sc-eZSpzM\"]{content:\"jjnszm,\"}/*!sc*/\n.kZcHWP{font-family:Courier,monospace;margin-left:10px;flex:1;overflow-x:hidden;text-overflow:ellipsis;}/*!sc*/\ndata-styled.g112[id=\"sc-jvKoal\"]{content:\"kZcHWP,\"}/*!sc*/\n.iPCVMX{outline:0;color:inherit;width:100%;text-align:left;cursor:pointer;padding:10px 30px 10px 20px;border-radius:4px 4px 0 0;background-color:#11171a;display:flex;white-space:nowrap;align-items:center;border:1px solid transparent;border-bottom:0;transition:border-color 0.25s ease;}/*!sc*/\n.iPCVMX ..sc-jvKoal{color:#ffffff;}/*!sc*/\n.iPCVMX:focus{box-shadow:inset 0 2px 2px rgba(0, 0, 0, 0.45),0 2px 0 rgba(128, 128, 128, 0.25);}/*!sc*/\ndata-styled.g113[id=\"sc-buTqWO\"]{content:\"iPCVMX,\"}/*!sc*/\n.dynMBc{font-size:0.929em;line-height:20px;background-color:#2F8132;color:#ffffff;padding:3px 10px;text-transform:uppercase;font-family:Montserrat,sans-serif;margin:0;}/*!sc*/\n.kwcmyC{font-size:0.929em;line-height:20px;background-color:#186FAF;color:#ffffff;padding:3px 10px;text-transform:uppercase;font-family:Montserrat,sans-serif;margin:0;}/*!sc*/\n.dBzsUh{font-size:0.929em;line-height:20px;background-color:#95507c;color:#ffffff;padding:3px 10px;text-transform:uppercase;font-family:Montserrat,sans-serif;margin:0;}/*!sc*/\n.gKcHYQ{font-size:0.929em;line-height:20px;background-color:#cc3333;color:#ffffff;padding:3px 10px;text-transform:uppercase;font-family:Montserrat,sans-serif;margin:0;}/*!sc*/\ndata-styled.g114[id=\"sc-fQLpxn\"]{content:\"dynMBc,kwcmyC,dBzsUh,gKcHYQ,\"}/*!sc*/\n.ga-DQLq{position:absolute;width:100%;z-index:100;background:#fafafa;color:#263238;box-sizing:border-box;box-shadow:0 0 6px rgba(0, 0, 0, 0.33);overflow:hidden;border-bottom-left-radius:4px;border-bottom-right-radius:4px;transition:all 0.25s ease;visibility:hidden;transform:translateY(-50%) scaleY(0);}/*!sc*/\ndata-styled.g115[id=\"sc-ecJghI\"]{content:\"ga-DQLq,\"}/*!sc*/\n.icOxsG{padding:10px;}/*!sc*/\ndata-styled.g116[id=\"sc-iyBeIh\"]{content:\"icOxsG,\"}/*!sc*/\n.okJpy{padding:5px;border:1px solid #ccc;background:#fff;word-break:break-all;color:#32329f;}/*!sc*/\n.okJpy >span{color:#333333;}/*!sc*/\ndata-styled.g117[id=\"sc-xKhEK\"]{content:\"okJpy,\"}/*!sc*/\n.foplsk{text-transform:lowercase;margin-left:0;line-height:1.5em;}/*!sc*/\ndata-styled.g118[id=\"sc-eTCgfj\"]{content:\"foplsk,\"}/*!sc*/\n.lkmdtA{display:block;border:0;width:100%;text-align:left;padding:10px;border-radius:2px;margin-bottom:4px;line-height:1.5em;cursor:pointer;color:#1d8127;background-color:rgba(29,129,39,0.07);}/*!sc*/\n.lkmdtA:focus{outline:auto #1d8127;}/*!sc*/\n.ifAHvq{display:block;border:0;width:100%;text-align:left;padding:10px;border-radius:2px;margin-bottom:4px;line-height:1.5em;cursor:pointer;color:#d41f1c;background-color:rgba(212,31,28,0.07);}/*!sc*/\n.ifAHvq:focus{outline:auto #d41f1c;}/*!sc*/\n.kQCDrg{display:block;border:0;width:100%;text-align:left;padding:10px;border-radius:2px;margin-bottom:4px;line-height:1.5em;cursor:pointer;color:#d41f1c;background-color:rgba(212,31,28,0.07);cursor:default;}/*!sc*/\n.kQCDrg:focus{outline:auto #d41f1c;}/*!sc*/\n.kQCDrg::before{content:\"—\";font-weight:bold;width:1.5em;text-align:center;display:inline-block;vertical-align:top;}/*!sc*/\n.kQCDrg:focus{outline:0;}/*!sc*/\n.oZuve{display:block;border:0;width:100%;text-align:left;padding:10px;border-radius:2px;margin-bottom:4px;line-height:1.5em;cursor:pointer;color:#1d8127;background-color:rgba(29,129,39,0.07);cursor:default;}/*!sc*/\n.oZuve:focus{outline:auto #1d8127;}/*!sc*/\n.oZuve::before{content:\"—\";font-weight:bold;width:1.5em;text-align:center;display:inline-block;vertical-align:top;}/*!sc*/\n.oZuve:focus{outline:0;}/*!sc*/\ndata-styled.g120[id=\"sc-jIDBmd\"]{content:\"lkmdtA,ifAHvq,kQCDrg,oZuve,\"}/*!sc*/\n.fBhAXU{vertical-align:top;}/*!sc*/\ndata-styled.g123[id=\"sc-eJvlPh\"]{content:\"fBhAXU,\"}/*!sc*/\n.kjrVcG{font-size:1.3em;padding:0.2em 0;margin:3em 0 1.1em;color:#333333;font-weight:normal;}/*!sc*/\ndata-styled.g124[id=\"sc-gDzyrw\"]{content:\"kjrVcG,\"}/*!sc*/\n.txIPi{margin-bottom:30px;}/*!sc*/\ndata-styled.g129[id=\"sc-bfjeOH\"]{content:\"txIPi,\"}/*!sc*/\n.crXcHD{user-select:none;width:20px;height:20px;align-self:center;display:flex;flex-direction:column;color:#32329f;}/*!sc*/\ndata-styled.g130[id=\"sc-cZnrqW\"]{content:\"crXcHD,\"}/*!sc*/\n.dsiHUZ{width:260px;background-color:#fafafa;overflow:hidden;display:flex;flex-direction:column;backface-visibility:hidden;height:100vh;position:sticky;position:-webkit-sticky;top:0;}/*!sc*/\n@media screen and (max-width: 50rem){.dsiHUZ{position:fixed;z-index:20;width:100%;background:#fafafa;display:none;}}/*!sc*/\n@media print{.dsiHUZ{display:none;}}/*!sc*/\ndata-styled.g131[id=\"sc-fstJre\"]{content:\"dsiHUZ,\"}/*!sc*/\n.bovaLG{outline:none;user-select:none;background-color:#f2f2f2;color:#32329f;display:none;cursor:pointer;position:fixed;right:20px;z-index:100;border-radius:50%;box-shadow:0 0 20px rgba(0, 0, 0, 0.3);bottom:44px;width:60px;height:60px;padding:0 20px;}/*!sc*/\n@media screen and (max-width: 50rem){.bovaLG{display:flex;}}/*!sc*/\n.bovaLG svg{color:#0065FB;}/*!sc*/\n@media print{.bovaLG{display:none;}}/*!sc*/\ndata-styled.g132[id=\"sc-jOlHRD\"]{content:\"bovaLG,\"}/*!sc*/\n.eHdqcJ{font-family:Roboto,sans-serif;font-size:14px;font-weight:400;line-height:1.5em;color:#333333;display:flex;position:relative;text-align:left;-webkit-font-smoothing:antialiased;font-smoothing:antialiased;text-rendering:optimizeSpeed!important;tap-highlight-color:rgba(0, 0, 0, 0);text-size-adjust:100%;}/*!sc*/\n.eHdqcJ *{box-sizing:border-box;-webkit-tap-highlight-color:rgba(255, 255, 255, 0);}/*!sc*/\ndata-styled.g133[id=\"sc-Pgsbw\"]{content:\"eHdqcJ,\"}/*!sc*/\n.buanwU{z-index:1;position:relative;overflow:hidden;width:calc(100% - 260px);contain:layout;}/*!sc*/\n@media print,screen and (max-width: 50rem){.buanwU{width:100%;}}/*!sc*/\ndata-styled.g134[id=\"sc-fkYqBV\"]{content:\"buanwU,\"}/*!sc*/\n.iZqpqg{background:#263238;position:absolute;top:0;bottom:0;right:0;width:calc((100% - 260px) * 0.4);}/*!sc*/\n@media print,screen and (max-width: 75rem){.iZqpqg{display:none;}}/*!sc*/\ndata-styled.g135[id=\"sc-evkzZa\"]{content:\"iZqpqg,\"}/*!sc*/\n.gzMPIt{padding:5px 0;}/*!sc*/\ndata-styled.g136[id=\"sc-iRcyzz\"]{content:\"gzMPIt,\"}/*!sc*/\n.iOkeQy{width:calc(100% - 40px);box-sizing:border-box;margin:0 20px;padding:5px 10px 5px 20px;border:0;border-bottom:1px solid #e1e1e1;font-family:Roboto,sans-serif;font-weight:bold;font-size:13px;color:#333333;background-color:transparent;outline:none;}/*!sc*/\ndata-styled.g137[id=\"sc-lhsSio\"]{content:\"iOkeQy,\"}/*!sc*/\n.SikXG{position:absolute;left:20px;height:1.8em;width:0.9em;}/*!sc*/\n.SikXG path{fill:#333333;}/*!sc*/\ndata-styled.g138[id=\"sc-enPhjR\"]{content:\"SikXG,\"}/*!sc*/\n</style>\n  <link href=\"https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700\" rel=\"stylesheet\">\n</head>\n\n<body>\n  \n      <div id=\"redoc\"><div class=\"sc-Pgsbw eHdqcJ redoc-wrap\"><div class=\"sc-fstJre dsiHUZ menu-content\" style=\"top:0px;height:calc(100vh - 0px)\"><div role=\"search\" class=\"sc-iRcyzz gzMPIt\"><svg class=\"sc-enPhjR SikXG search-icon\" version=\"1.1\" viewBox=\"0 0 1000 1000\" x=\"0px\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0px\"><path d=\"M968.2,849.4L667.3,549c83.9-136.5,66.7-317.4-51.7-435.6C477.1-25,252.5-25,113.9,113.4c-138.5,138.3-138.5,362.6,0,501C219.2,730.1,413.2,743,547.6,666.5l301.9,301.4c43.6,43.6,76.9,14.9,104.2-12.4C981,928.3,1011.8,893,968.2,849.4z M524.5,522c-88.9,88.7-233,88.7-321.8,0c-88.9-88.7-88.9-232.6,0-321.3c88.9-88.7,233-88.7,321.8,0C613.4,289.4,613.4,433.3,524.5,522z\"></path></svg><input placeholder=\"Search...\" aria-label=\"Search\" type=\"text\" class=\"sc-lhsSio iOkeQy search-input\" value=\"\"/></div><div class=\"sc-eknHtZ ghzOpX scrollbar-container undefined\"><ul role=\"menu\" class=\"sc-zOxLx gAPKXX\"><li tabindex=\"0\" depth=\"1\" data-item-id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API\" role=\"menuitem\" aria-label=\"ChangeDetection.io Web page monitoring and notifications API\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"ChangeDetection.io Web page monitoring and notifications API\" class=\"sc-gWaSiO cxcra\">ChangeDetection.io Web page monitoring and notifications API</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Where-to-find-my-API-key\" role=\"menuitem\" aria-label=\"Where to find my API key?\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz fTDmgC -depth2\"><span width=\"calc(100% - 38px)\" title=\"Where to find my API key?\" class=\"sc-gWaSiO cxcra\">Where to find my API key?</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Connection-URL\" role=\"menuitem\" aria-label=\"Connection URL\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz fTDmgC -depth2\"><span width=\"calc(100% - 38px)\" title=\"Connection URL\" class=\"sc-gWaSiO cxcra\">Connection URL</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Authentication\" role=\"menuitem\" aria-label=\"Authentication\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz fTDmgC -depth2\"><span width=\"calc(100% - 38px)\" title=\"Authentication\" class=\"sc-gWaSiO cxcra\">Authentication</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Watch-Management\" role=\"menuitem\" aria-label=\"Watch Management\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Watch Management\" class=\"sc-gWaSiO cxcra\">Watch Management</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Watch-Management/operation/listWatches\" role=\"menuitem\" aria-label=\"List all watches\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">List all watches</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Watch-Management/operation/createWatch\" role=\"menuitem\" aria-label=\"Create a new watch\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"post\" class=\"sc-jxYSNo NmQLu operation-type post\">post</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Create a new watch</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Watch-Management/operation/getWatch\" role=\"menuitem\" aria-label=\"Get single watch\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get single watch</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Watch-Management/operation/updateWatch\" role=\"menuitem\" aria-label=\"Update watch\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"put\" class=\"sc-jxYSNo NmQLu operation-type put\">put</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Update watch</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Watch-Management/operation/deleteWatch\" role=\"menuitem\" aria-label=\"Delete watch\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"delete\" class=\"sc-jxYSNo NmQLu operation-type delete\">del</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Delete watch</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Watch-History\" role=\"menuitem\" aria-label=\"Watch History\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Watch History\" class=\"sc-gWaSiO cxcra\">Watch History</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Watch-History/operation/getWatchHistory\" role=\"menuitem\" aria-label=\"Get watch history\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get watch history</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Watch-History/operation/getWatchHistoryDiff\" role=\"menuitem\" aria-label=\"Get the difference between two snapshots\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get the difference between two snapshots</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Snapshots\" role=\"menuitem\" aria-label=\"Snapshots\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Snapshots\" class=\"sc-gWaSiO cxcra\">Snapshots</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Snapshots/operation/getWatchSnapshot\" role=\"menuitem\" aria-label=\"Get single snapshot\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get single snapshot</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Favicon\" role=\"menuitem\" aria-label=\"Favicon\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Favicon\" class=\"sc-gWaSiO cxcra\">Favicon</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Favicon/operation/getWatchFavicon\" role=\"menuitem\" aria-label=\"Get watch favicon\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get watch favicon</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Group-Tag-Management\" role=\"menuitem\" aria-label=\"Group / Tag Management\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Group / Tag Management\" class=\"sc-gWaSiO cxcra\">Group / Tag Management</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Group-Tag-Management/operation/listTags\" role=\"menuitem\" aria-label=\"List all tags\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">List all tags</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Group-Tag-Management/operation/createTag\" role=\"menuitem\" aria-label=\"Create tag\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"post\" class=\"sc-jxYSNo NmQLu operation-type post\">post</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Create tag</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Group-Tag-Management/operation/getTag\" role=\"menuitem\" aria-label=\"Get single tag\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get single tag</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Group-Tag-Management/operation/updateTag\" role=\"menuitem\" aria-label=\"Update tag\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"put\" class=\"sc-jxYSNo NmQLu operation-type put\">put</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Update tag</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Group-Tag-Management/operation/deleteTag\" role=\"menuitem\" aria-label=\"Delete tag\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"delete\" class=\"sc-jxYSNo NmQLu operation-type delete\">del</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Delete tag</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Notifications\" role=\"menuitem\" aria-label=\"Notifications\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Notifications\" class=\"sc-gWaSiO cxcra\">Notifications</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Notifications/operation/getNotifications\" role=\"menuitem\" aria-label=\"Get notification URLs\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get notification URLs</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Notifications/operation/addNotifications\" role=\"menuitem\" aria-label=\"Add notification URLs\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"post\" class=\"sc-jxYSNo NmQLu operation-type post\">post</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Add notification URLs</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Notifications/operation/replaceNotifications\" role=\"menuitem\" aria-label=\"Replace notification URLs\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"put\" class=\"sc-jxYSNo NmQLu operation-type put\">put</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Replace notification URLs</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Notifications/operation/deleteNotifications\" role=\"menuitem\" aria-label=\"Delete notification URLs\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"delete\" class=\"sc-jxYSNo NmQLu operation-type delete\">del</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Delete notification URLs</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Search\" role=\"menuitem\" aria-label=\"Search\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Search\" class=\"sc-gWaSiO cxcra\">Search</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Search/operation/searchWatches\" role=\"menuitem\" aria-label=\"Search watches\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Search watches</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Import\" role=\"menuitem\" aria-label=\"Import\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Import\" class=\"sc-gWaSiO cxcra\">Import</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Import/operation/importWatches\" role=\"menuitem\" aria-label=\"Import watch URLs with configuration\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"post\" class=\"sc-jxYSNo NmQLu operation-type post\">post</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Import watch URLs with configuration</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/System-Information\" role=\"menuitem\" aria-label=\"System Information\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"System Information\" class=\"sc-gWaSiO cxcra\">System Information</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/System-Information/operation/getSystemInfo\" role=\"menuitem\" aria-label=\"Get system information\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get system information</span></label></li></ul></li><li tabindex=\"0\" depth=\"1\" data-item-id=\"tag/Plugin-API-Extensions\" role=\"menuitem\" aria-label=\"Plugin API Extensions\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz lgPGwq -depth1\"><span width=\"calc(100% - 38px)\" title=\"Plugin API Extensions\" class=\"sc-gWaSiO cxcra\">Plugin API Extensions</span><svg class=\"sc-dntSTA dUlzCe\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></label><ul class=\"sc-zOxLx dQnkdy\"><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Plugin-API-Extensions/How-Processor-Plugins-Extend-the-API\" role=\"menuitem\" aria-label=\"How Processor Plugins Extend the API\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz fTDmgC -depth2\"><span width=\"calc(100% - 38px)\" title=\"How Processor Plugins Extend the API\" class=\"sc-gWaSiO cxcra\">How Processor Plugins Extend the API</span></label></li><li tabindex=\"0\" depth=\"2\" data-item-id=\"tag/Plugin-API-Extensions/operation/getFullApiSpec\" role=\"menuitem\" aria-label=\"Get full live API spec\" aria-expanded=\"false\" class=\"sc-cgHfjM ixknQI\"><label class=\"sc-fpikKz iHRgeo -depth2\"><span type=\"get\" class=\"sc-jxYSNo NmQLu operation-type get\">get</span><span tabindex=\"0\" width=\"calc(100% - 38px)\" class=\"sc-gWaSiO cxcra\">Get full live API spec</span></label></li></ul></li></ul><div class=\"sc-kSaXSp QuyG\"><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://redocly.com/redoc/\">API docs by Redocly</a></div></div></div><div class=\"sc-jOlHRD bovaLG\"><div class=\"sc-cZnrqW crXcHD\"><svg class=\"\" style=\"transform:translate(2px, -4px) rotate(180deg);transition:transform 0.2s ease\" viewBox=\"0 0 926.23699 573.74994\" version=\"1.1\" x=\"0px\" y=\"0px\" width=\"15\" height=\"15\"><g transform=\"translate(904.92214,-879.1482)\"><path d=\"\n          m -673.67664,1221.6502 -231.2455,-231.24803 55.6165,\n          -55.627 c 30.5891,-30.59485 56.1806,-55.627 56.8701,-55.627 0.6894,\n          0 79.8637,78.60862 175.9427,174.68583 l 174.6892,174.6858 174.6892,\n          -174.6858 c 96.079,-96.07721 175.253196,-174.68583 175.942696,\n          -174.68583 0.6895,0 26.281,25.03215 56.8701,\n          55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864\n          -231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,\n          -104.0616 -231.873,-231.248 z\n        \" fill=\"currentColor\"></path></g></svg><svg class=\"\" style=\"transform:translate(2px, 4px);transition:transform 0.2s ease\" viewBox=\"0 0 926.23699 573.74994\" version=\"1.1\" x=\"0px\" y=\"0px\" width=\"15\" height=\"15\"><g transform=\"translate(904.92214,-879.1482)\"><path d=\"\n          m -673.67664,1221.6502 -231.2455,-231.24803 55.6165,\n          -55.627 c 30.5891,-30.59485 56.1806,-55.627 56.8701,-55.627 0.6894,\n          0 79.8637,78.60862 175.9427,174.68583 l 174.6892,174.6858 174.6892,\n          -174.6858 c 96.079,-96.07721 175.253196,-174.68583 175.942696,\n          -174.68583 0.6895,0 26.281,25.03215 56.8701,\n          55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864\n          -231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,\n          -104.0616 -231.873,-231.248 z\n        \" fill=\"currentColor\"></path></g></svg></div></div><div class=\"sc-fkYqBV buanwU api-content\"><div class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU api-info\"><h1 class=\"sc-hwkwBN sc-jCWzJg wYHiz hPcPCj\">ChangeDetection.io API<!-- --> <span>(<!-- -->0.1.6<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><div class=\"sc-erPUmh eAqtbt\"><div class=\"sc-iRTMaw beOrEi\"> <span class=\"sc-jVxTAy hijBKj\">URL: <a href=\"https://github.com/dgtlmoon/changedetection.io\">https://github.com/dgtlmoon/changedetection.io</a></span> <span class=\"sc-jVxTAy hijBKj\">License:<!-- --> <a href=\"https://www.apache.org/licenses/LICENSE-2.0.html\">Apache 2.0</a></span> </div></div></div><div data-role=\"redoc-summary\" html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><div data-role=\"redoc-description\" html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div></div></div></div><div id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API\" data-section-id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#section/ChangeDetection.io-Web-page-monitoring-and-notifications-API\" aria-label=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API\"></a>ChangeDetection.io Web page monitoring and notifications API</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;REST API for managing Page watches, Group tags, and Notifications.&lt;/p&gt;\n&lt;p&gt;changedetection.io can be driven by its built in simple API, in the examples below you will also find &lt;code&gt;curl&lt;/code&gt; command line and &lt;code&gt;python&lt;/code&gt; examples to help you get started faster.&lt;/p&gt;\n\"><p>REST API for managing Page watches, Group tags, and Notifications.</p>\n<p>changedetection.io can be driven by its built in simple API, in the examples below you will also find <code>curl</code> command line and <code>python</code> examples to help you get started faster.</p>\n</div></div></div><div id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Where-to-find-my-API-key\" data-section-id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Where-to-find-my-API-key\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-dYwGCk cXqSZD\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Where-to-find-my-API-key\" aria-label=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Where-to-find-my-API-key\"></a>Where to find my API key?</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;The API key can be easily found under the &lt;strong&gt;SETTINGS&lt;/strong&gt; then &lt;strong&gt;API&lt;/strong&gt; tab of changedetection.io dashboard.&lt;br&gt;Simply click the API key to automatically copy it to your clipboard.&lt;/p&gt;\n&lt;p&gt;&lt;img src=&quot;./where-to-get-api-key.jpeg&quot; alt=&quot;Where to find the API key&quot;&gt;&lt;/p&gt;\n\"><p>The API key can be easily found under the <strong>SETTINGS</strong> then <strong>API</strong> tab of changedetection.io dashboard.<br>Simply click the API key to automatically copy it to your clipboard.</p>\n<p><img src=\"./where-to-get-api-key.jpeg\" alt=\"Where to find the API key\"></p>\n</div></div></div><div id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Connection-URL\" data-section-id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Connection-URL\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-dYwGCk cXqSZD\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Connection-URL\" aria-label=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Connection-URL\"></a>Connection URL</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;The API can be found at &lt;code&gt;/api/v1/&lt;/code&gt;, so for example if you run changedetection.io locally on port 5000, then URL would be &lt;code&gt;http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history&lt;/code&gt;.&lt;/p&gt;\n&lt;p&gt;If you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example:&lt;br&gt;&lt;code&gt;https://&amp;lt;your login url&amp;gt;/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history&lt;/code&gt;&lt;/p&gt;\n\"><p>The API can be found at <code>/api/v1/</code>, so for example if you run changedetection.io locally on port 5000, then URL would be <code>http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history</code>.</p>\n<p>If you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example:<br><code>https://&lt;your login url&gt;/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history</code></p>\n</div></div></div><div id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Authentication\" data-section-id=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Authentication\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-dYwGCk cXqSZD\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Authentication\" aria-label=\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Authentication\"></a>Authentication</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Almost all API requests require some authentication, this is provided as an &lt;strong&gt;API Key&lt;/strong&gt; in the header of the HTTP request.&lt;/p&gt;\n&lt;p&gt;For example: &lt;code&gt;x-api-key: YOUR_API_KEY&lt;/code&gt;&lt;/p&gt;\n\"><p>Almost all API requests require some authentication, this is provided as an <strong>API Key</strong> in the header of the HTTP request.</p>\n<p>For example: <code>x-api-key: YOUR_API_KEY</code></p>\n</div></div></div><div id=\"tag/Watch-Management\" data-section-id=\"tag/Watch-Management\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-Management\" aria-label=\"tag/Watch-Management\"></a>Watch Management</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Core functionality for managing web page monitors. Create, retrieve, update, and delete individual watches. \nEach watch represents a single URL being monitored for changes, with configurable settings for check intervals, \nnotification preferences, and content filtering options.&lt;/p&gt;\n\"><p>Core functionality for managing web page monitors. Create, retrieve, update, and delete individual watches. \nEach watch represents a single URL being monitored for changes, with configurable settings for check intervals, \nnotification preferences, and content filtering options.</p>\n</div></div></div><div id=\"tag/Watch-Management/operation/listWatches\" data-section-id=\"tag/Watch-Management/operation/listWatches\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/listWatches\" id=\"operation/listWatches\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-Management/operation/listWatches\" aria-label=\"tag/Watch-Management/operation/listWatches\"></a>List all watches<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Return concise list of available web page change monitors (watches) and basic info&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Return concise list of available web page change monitors (watches) and basic info</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">query<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"recheck_all\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">recheck_all</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Value<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> </div> <div><div html=\"&lt;p&gt;Set to 1 to force recheck of all watches&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Set to 1 to force recheck of all watches</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"tag\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tag</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div> <div><div html=\"&lt;p&gt;Tag name to filter results&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag name to filter results</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;List of watches&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>List of watches</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/watch</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2a9ha_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2a9ha_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2a9ha_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2a9ha_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2a9ha_0\" aria-labelledby=\"tab_R_2a9ha_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2a9ha_1\" aria-labelledby=\"tab_R_2a9ha_1\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2e9ha_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2e9ha_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2e9ha_0\" aria-labelledby=\"tab_R_2e9ha_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"uuid\"</span>: <span class=\"token string\">&quot;095be615-a8ad-4c33-8e9c-c7612fbf6c9f&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"url\"</span>: <span class=\"token string\">&quot;http://example.com?id={{1+1}} - the raw URL&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"link\"</span>: <span class=\"token string\">&quot;http://example.com?id=2 - the rendered URL, always use this for listing.&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;Example Website Monitor - manually entered title/description&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"page_title\"</span>: <span class=\"token string\">&quot;The HTML &lt;title&gt; from the page&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"tags\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;550e8400-e29b-41d4-a716-446655440000&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"paused\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"method\"</span>: <span class=\"token string\">&quot;GET&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"fetch_backend\"</span>: <span class=\"token string\">&quot;html_requests&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"last_checked\"</span>: <span class=\"token number\">1640995200</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"last_changed\"</span>: <span class=\"token number\">1640995200</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"7c9e6b8d-f2a1-4e5c-9d3b-8a7f6e4c2d1a\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"uuid\"</span>: <span class=\"token string\">&quot;7c9e6b8d-f2a1-4e5c-9d3b-8a7f6e4c2d1a&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"url\"</span>: <span class=\"token string\">&quot;http://example.com?id={{1+1}} - the raw URL&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"link\"</span>: <span class=\"token string\">&quot;http://example.com?id=2 - the rendered URL, always use this for listing.&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;News Site Tracker - manually entered title/description&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"page_title\"</span>: <span class=\"token string\">&quot;The HTML &lt;title&gt; from the page&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"tags\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;330e8400-e29b-41d4-a716-446655440001&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"paused\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"method\"</span>: <span class=\"token string\">&quot;GET&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"fetch_backend\"</span>: <span class=\"token string\">&quot;html_webdriver&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"last_checked\"</span>: <span class=\"token number\">1640998800</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"last_changed\"</span>: <span class=\"token number\">1640995200</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Watch-Management/operation/createWatch\" data-section-id=\"tag/Watch-Management/operation/createWatch\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/createWatch\" id=\"operation/createWatch\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-Management/operation/createWatch\" aria-label=\"tag/Watch-Management/operation/createWatch\"></a>Create a new watch<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Create a single web page change monitor (watch). Requires at least &lt;code&gt;url&lt;/code&gt; to be set.&lt;/p&gt;\n&lt;p&gt;Every watch can be configured with:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;strong&gt;Processor mode&lt;/strong&gt;: &lt;code&gt;processor&lt;/code&gt; field (&lt;code&gt;restock_diff&lt;/code&gt; or &lt;code&gt;text_json_diff&lt;/code&gt; - default)&lt;/li&gt;\n&lt;li&gt;&lt;strong&gt;Notification settings&lt;/strong&gt;: &lt;code&gt;notification_urls&lt;/code&gt; (array), &lt;code&gt;notification_title&lt;/code&gt;, &lt;code&gt;notification_body&lt;/code&gt;, &lt;code&gt;notification_format&lt;/code&gt;, &lt;code&gt;notification_muted&lt;/code&gt;&lt;/li&gt;\n&lt;li&gt;&lt;strong&gt;Tags/Groups&lt;/strong&gt;: &lt;code&gt;tag&lt;/code&gt; (UUID string) or &lt;code&gt;tags&lt;/code&gt; (array of UUIDs)&lt;/li&gt;\n&lt;li&gt;&lt;strong&gt;Check settings&lt;/strong&gt;: &lt;code&gt;time_between_check&lt;/code&gt;, &lt;code&gt;paused&lt;/code&gt;, &lt;code&gt;method&lt;/code&gt;, &lt;code&gt;fetch_backend&lt;/code&gt;&lt;/li&gt;\n&lt;li&gt;&lt;strong&gt;Advanced options&lt;/strong&gt;: &lt;code&gt;headers&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, &lt;code&gt;proxy&lt;/code&gt;, &lt;code&gt;browser_steps&lt;/code&gt;, and more&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Create a single web page change monitor (watch). Requires at least <code>url</code> to be set.</p>\n<p>Every watch can be configured with:</p>\n<ul>\n<li><strong>Processor mode</strong>: <code>processor</code> field (<code>restock_diff</code> or <code>text_json_diff</code> - default)</li>\n<li><strong>Notification settings</strong>: <code>notification_urls</code> (array), <code>notification_title</code>, <code>notification_body</code>, <code>notification_format</code>, <code>notification_muted</code></li>\n<li><strong>Tags/Groups</strong>: <code>tag</code> (UUID string) or <code>tags</code> (array of UUIDs)</li>\n<li><strong>Check settings</strong>: <code>time_between_check</code>, <code>paused</code>, <code>method</code>, <code>fetch_backend</code></li>\n<li><strong>Advanced options</strong>: <code>headers</code>, <code>body</code>, <code>proxy</code>, <code>browser_steps</code>, and more</li>\n</ul>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><h5 class=\"sc-eqYatC czjApA\">Request Body schema: <span class=\"sc-dNFkOE cFlAeY\">application/json</span><div class=\"sc-bEjUoa sc-iIvHqT sc-eTCgfj lhyyLL crXmiY foplsk\">required</div></h5><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"url\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">url</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uri<!-- -->&gt;<!-- --> </span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;URL to monitor for changes&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>URL to monitor for changes</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"title\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">title</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom title for the web page change monitor (watch), not to be confused with page_title&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom title for the web page change monitor (watch), not to be confused with page_title</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tag\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tag</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Tag UUID to associate with this web page change monitor (watch)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag UUID to associate with this web page change monitor (watch)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tags\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tags</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span></div> <div><div html=\"&lt;p&gt;Array of tag UUIDs&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Array of tag UUIDs</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"paused\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">paused</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div> <div><div html=\"&lt;p&gt;Whether the web page change monitor (watch) is paused&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether the web page change monitor (watch) is paused</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_muted\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_muted</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div> <div><div html=\"&lt;p&gt;Whether notifications are muted&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether notifications are muted</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"method\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">method</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;GET&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;POST&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;DELETE&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;PUT&quot;</span> </div> <div><div html=\"&lt;p&gt;HTTP method to use&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP method to use</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"fetch_backend\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">fetch_backend</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-cpclqO lhyyLL UZcrz\">^(system|html_requests|html_webdriver|extra_b...</span><button class=\"sc-gSifMm jBrfIx\">Show pattern</button></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;system&quot;</span></div> <div><div html=\"&lt;p&gt;Backend to use for fetching content. Common values:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;system&lt;/code&gt; (default) - Use the system-wide default fetcher&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html_requests&lt;/code&gt; - Fast requests-based fetcher&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html_webdriver&lt;/code&gt; - Browser-based fetcher (Playwright/Puppeteer)&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;extra_browser_*&lt;/code&gt; - Custom browser configurations (if configured)&lt;/li&gt;\n&lt;li&gt;Plugin-provided fetchers (if installed)&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Backend to use for fetching content. Common values:</p>\n<ul>\n<li><code>system</code> (default) - Use the system-wide default fetcher</li>\n<li><code>html_requests</code> - Fast requests-based fetcher</li>\n<li><code>html_webdriver</code> - Browser-based fetcher (Playwright/Puppeteer)</li>\n<li><code>extra_browser_*</code> - Custom browser configurations (if configured)</li>\n<li>Plugin-provided fetchers (if installed)</li>\n</ul>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"headers\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand headers\"><span class=\"property-name\">headers</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;HTTP headers to include in requests&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP headers to include in requests</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"body\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">body</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;HTTP request body&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP request body</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"proxy\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">proxy</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Proxy configuration&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Proxy configuration</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignore_status_codes\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignore_status_codes</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Ignore HTTP status code errors (boolean or null)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Ignore HTTP status code errors (boolean or null)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"webdriver_delay\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">webdriver_delay</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or null</span></div> <div><div html=\"&lt;p&gt;Delay in seconds for webdriver&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Delay in seconds for webdriver</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"webdriver_js_execute_code\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">webdriver_js_execute_code</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;JavaScript code to execute&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>JavaScript code to execute</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_between_check\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand time_between_check\"><span class=\"property-name\">time_between_check</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_between_check_use_default\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">time_between_check_use_default</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Whether to use global settings for time between checks - defaults to true if not set&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether to use global settings for time between checks - defaults to true if not set</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_urls\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_urls</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 1000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Notification URLs for this web page change monitor (watch). Maximum 100 URLs.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Notification URLs for this web page change monitor (watch). Maximum 100 URLs.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_title\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_title</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom notification title&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom notification title</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_body\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_body</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom notification body&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom notification body</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_format\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_format</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;html&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;htmlcolor&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;markdown&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;System default&quot;</span> </div> <div><div html=\"&lt;p&gt;Format for notifications&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Format for notifications</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"track_ldjson_price_data\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">track_ldjson_price_data</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Whether to track JSON-LD price data&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether to track JSON-LD price data</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"browser_steps\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand browser_steps\"><span class=\"property-name\">browser_steps</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">objects</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Browser automation steps. Maximum 100 steps allowed.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Browser automation steps. Maximum 100 steps allowed.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"processor\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">processor</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text_json_diff&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;restock_diff&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text_json_diff&quot;</span> </div> <div><div html=\"&lt;p&gt;Optional processor mode to use for change detection. Defaults to &lt;code&gt;text_json_diff&lt;/code&gt; if not specified.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Optional processor mode to use for change detection. Defaults to <code>text_json_diff</code> if not specified.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"include_filters\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">include_filters</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;CSS/XPath selectors to extract specific content from the page&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>CSS/XPath selectors to extract specific content from the page</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"subtractive_selectors\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">subtractive_selectors</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;CSS/XPath selectors to remove content from the page&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>CSS/XPath selectors to remove content from the page</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignore_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignore_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text patterns to ignore in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text patterns to ignore in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"trigger_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">trigger_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text/regex patterns that must be present to trigger a change&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text/regex patterns that must be present to trigger a change</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"text_should_not_be_present\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">text_should_not_be_present</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text that should NOT be present (triggers alert if found)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text that should NOT be present (triggers alert if found)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"extract_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">extract_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Regex patterns to extract specific text after filtering&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Regex patterns to extract specific text after filtering</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"trim_text_whitespace\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">trim_text_whitespace</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Strip leading/trailing whitespace from text&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Strip leading/trailing whitespace from text</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"sort_text_alphabetically\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">sort_text_alphabetically</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Sort lines alphabetically before comparison&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Sort lines alphabetically before comparison</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"remove_duplicate_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">remove_duplicate_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Remove duplicate lines from content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Remove duplicate lines from content</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"check_unique_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">check_unique_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Compare against all history for unique lines&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Compare against all history for unique lines</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"strip_ignored_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">strip_ignored_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Remove lines matching ignore patterns&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Remove lines matching ignore patterns</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_added\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_added</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include added text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include added text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_removed\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_removed</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include removed text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include removed text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_replaced\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_replaced</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include replaced text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include replaced text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"in_stock_only\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">in_stock_only</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Only trigger on in-stock transitions (restock_diff processor)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Only trigger on in-stock transitions (restock_diff processor)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"follow_price_changes\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">follow_price_changes</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Monitor and track price changes (restock_diff processor)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Monitor and track price changes (restock_diff processor)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"price_change_threshold_percent\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">price_change_threshold_percent</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">number or null</span></div> <div><div html=\"&lt;p&gt;Minimum price change percentage to trigger notification&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Minimum price change percentage to trigger notification</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_screenshot\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_screenshot</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Include screenshot in notifications (if supported by notification URL)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include screenshot in notifications (if supported by notification URL)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_failure_notification_send\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_failure_notification_send</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Send notification when filters fail to match content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Send notification when filters fail to match content</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"use_page_title_in_list\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">use_page_title_in_list</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Display page title in watch list (null = use system default)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Display page title in watch list (null = use system default)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"history_snapshot_max_length\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">history_snapshot_max_length</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->[ 1 .. 1000 ]<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Maximum number of history snapshots to keep (null = use system default)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Maximum number of history snapshots to keep (null = use system default)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_schedule_limit\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand time_schedule_limit\"><span class=\"property-name\">time_schedule_limit</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;Weekly schedule limiting when checks can run&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Weekly schedule limiting when checks can run</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"conditions\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand conditions\"><span class=\"property-name\">conditions</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">objects</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Array of condition rules for change detection logic (empty array when not set)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Array of condition rules for change detection logic (empty array when not set)</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"conditions_match_logic\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">conditions_match_logic</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ALL&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ALL&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ANY&quot;</span> </div> <div><div html=\"&lt;p&gt;Logic operator - ALL (match all conditions) or ANY (match any condition)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Logic operator - ALL (match all conditions) or ANY (match any condition)</p>\n</div></div></div></td></tr></tbody></table><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Web page change monitor (watch) created successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Web page change monitor (watch) created successfully</p>\n</div></button></div><div><button class=\"sc-jIDBmd ifAHvq\"><svg class=\"sc-dntSTA jKYZgc\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">500<!-- --> </strong><div html=\"&lt;p&gt;Server error&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Server error</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"post\" class=\"sc-fQLpxn kwcmyC http-verb post\">post</span><span class=\"sc-jvKoal kZcHWP\">/watch</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2aaha_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2aaha_0\" tabindex=\"0\" data-rttab=\"true\">Payload</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2aaha_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2aaha_1\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2aaha_2\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2aaha_2\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2aaha_0\" aria-labelledby=\"tab_R_2aaha_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"url\"</span>: <span class=\"token string\">&quot;</span><a href=\"https://example.com\">https://example.com</a><span class=\"token string\">&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;Example Site Monitor&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token number\">1</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2aaha_1\" aria-labelledby=\"tab_R_2aaha_1\"></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2aaha_2\" aria-labelledby=\"tab_R_2aaha_2\"></div></div></div></div></div></div><div id=\"tag/Watch-Management/operation/getWatch\" data-section-id=\"tag/Watch-Management/operation/getWatch\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getWatch\" id=\"operation/getWatch\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-Management/operation/getWatch\" aria-label=\"tag/Watch-Management/operation/getWatch\"></a>Get single watch<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Retrieve web page change monitor (watch) information and set muted/paused status. Returns the FULL Watch JSON.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Retrieve web page change monitor (watch) information and set muted/paused status. Returns the FULL Watch JSON.</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Web page change monitor (watch) unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Web page change monitor (watch) unique ID</p>\n</div></div></div></td></tr></tbody></table></div><div><h5 class=\"sc-eqYatC czjApA\">query<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"recheck\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">recheck</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> </div> <div><div html=\"&lt;p&gt;Recheck this web page change monitor (watch)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Recheck this web page change monitor (watch)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"paused\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">paused</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;paused&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;unpaused&quot;</span> </div> <div><div html=\"&lt;p&gt;Set pause state&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Set pause state</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"muted\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">muted</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;muted&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;unmuted&quot;</span> </div> <div><div html=\"&lt;p&gt;Set mute state&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Set mute state</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Watch information or operation result&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Watch information or operation result</p>\n</div></button></div><div><button class=\"sc-jIDBmd ifAHvq\"><svg class=\"sc-dntSTA jKYZgc\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">404<!-- --> </strong><div html=\"&lt;p&gt;Web page change monitor (watch) not found&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Web page change monitor (watch) not found</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/watch/{uuid}</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2abha_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2abha_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2abha_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2abha_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2abha_0\" aria-labelledby=\"tab_R_2abha_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2abha_1\" aria-labelledby=\"tab_R_2abha_1\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2ebha_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2ebha_0\" tabindex=\"0\" data-rttab=\"true\">200</li><li class=\"tab-error\" role=\"tab\" id=\"tab_R_2ebha_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2ebha_1\" data-rttab=\"true\">404</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2ebha_0\" aria-labelledby=\"tab_R_2ebha_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-cCVJLD sc-gsJsQu dbfEBv ehbHlf\"><svg class=\"sc-pYNGo eyTvTk\" xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg><select class=\"dropdown-select\"><option value=\"application/json\" selected=\"\">application/json</option><option value=\"text/plain\">text/plain</option></select><label>application/json</label></div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"uuid\"</span>: <span class=\"token string\">&quot;095be615-a8ad-4c33-8e9c-c7612fbf6c9f&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"date_created\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"url\"</span>: <span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tag\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tags\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"paused\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"method\"</span>: <span class=\"token string\">&quot;GET&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"fetch_backend\"</span>: <span class=\"token string\">&quot;system&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"headers\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"property1\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"property2\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"body\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"proxy\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"ignore_status_codes\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"webdriver_delay\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"webdriver_js_execute_code\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"weeks\"</span>: <span class=\"token number\">52000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"days\"</span>: <span class=\"token number\">365000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token number\">8760000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token number\">525600000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"seconds\"</span>: <span class=\"token number\">31536000000</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check_use_default\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_body\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_format\"</span>: <span class=\"token string\">&quot;text&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"track_ldjson_price_data\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"browser_steps\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"operation\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"selector\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"optional_value\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"processor\"</span>: <span class=\"token string\">&quot;restock_diff&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"include_filters\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"subtractive_selectors\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"ignore_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"trigger_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"text_should_not_be_present\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"extract_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"trim_text_whitespace\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"sort_text_alphabetically\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"remove_duplicate_lines\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"check_unique_lines\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"strip_ignored_lines\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_added\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_removed\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_replaced\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"in_stock_only\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"follow_price_changes\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"price_change_threshold_percent\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"has_ldjson_price_data\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_screenshot\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_failure_notification_send\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"use_page_title_in_list\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"history_snapshot_max_length\"</span>: <span class=\"token number\">1</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_schedule_limit\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"monday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"tuesday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"wednesday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"thursday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"friday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"saturday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"sunday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"conditions\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"field\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"operator\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"value\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"conditions_match_logic\"</span>: <span class=\"token string\">&quot;ALL&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"last_checked\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"last_changed\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"last_error\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"last_viewed\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"link\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"page_title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"check_count\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"fetch_time\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"previous_md5\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"previous_md5_before_filters\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"consecutive_filter_failures\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"last_notification_error\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_alert_count\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"content-type\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"remote_server_reply\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"browser_steps_last_error_step\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"viewed\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"history_n\"</span>: <span class=\"token number\">0</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2ebha_1\" aria-labelledby=\"tab_R_2ebha_1\"></div></div></div></div></div></div><div id=\"tag/Watch-Management/operation/updateWatch\" data-section-id=\"tag/Watch-Management/operation/updateWatch\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/updateWatch\" id=\"operation/updateWatch\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-Management/operation/updateWatch\" aria-label=\"tag/Watch-Management/operation/updateWatch\"></a>Update watch<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Update an existing web page change monitor (watch) using JSON. Accepts the same structure as returned in &lt;a href=&quot;#operation/getWatch&quot;&gt;get single watch information&lt;/a&gt;.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Update an existing web page change monitor (watch) using JSON. Accepts the same structure as returned in <a href=\"#operation/getWatch\">get single watch information</a>.</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Web page change monitor (watch) unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Web page change monitor (watch) unique ID</p>\n</div></div></div></td></tr></tbody></table></div><h5 class=\"sc-eqYatC czjApA\">Request Body schema: <span class=\"sc-dNFkOE cFlAeY\">application/json</span><div class=\"sc-bEjUoa sc-iIvHqT sc-eTCgfj lhyyLL crXmiY foplsk\">required</div></h5><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"url\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">url</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uri<!-- -->&gt;<!-- --> </span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;URL to monitor for changes&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>URL to monitor for changes</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"title\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">title</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom title for the web page change monitor (watch), not to be confused with page_title&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom title for the web page change monitor (watch), not to be confused with page_title</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tag\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tag</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Tag UUID to associate with this web page change monitor (watch)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag UUID to associate with this web page change monitor (watch)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tags\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tags</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span></div> <div><div html=\"&lt;p&gt;Array of tag UUIDs&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Array of tag UUIDs</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"paused\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">paused</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div> <div><div html=\"&lt;p&gt;Whether the web page change monitor (watch) is paused&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether the web page change monitor (watch) is paused</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_muted\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_muted</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div> <div><div html=\"&lt;p&gt;Whether notifications are muted&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether notifications are muted</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"method\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">method</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;GET&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;POST&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;DELETE&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;PUT&quot;</span> </div> <div><div html=\"&lt;p&gt;HTTP method to use&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP method to use</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"fetch_backend\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">fetch_backend</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-cpclqO lhyyLL UZcrz\">^(system|html_requests|html_webdriver|extra_b...</span><button class=\"sc-gSifMm jBrfIx\">Show pattern</button></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;system&quot;</span></div> <div><div html=\"&lt;p&gt;Backend to use for fetching content. Common values:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;system&lt;/code&gt; (default) - Use the system-wide default fetcher&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html_requests&lt;/code&gt; - Fast requests-based fetcher&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html_webdriver&lt;/code&gt; - Browser-based fetcher (Playwright/Puppeteer)&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;extra_browser_*&lt;/code&gt; - Custom browser configurations (if configured)&lt;/li&gt;\n&lt;li&gt;Plugin-provided fetchers (if installed)&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Backend to use for fetching content. Common values:</p>\n<ul>\n<li><code>system</code> (default) - Use the system-wide default fetcher</li>\n<li><code>html_requests</code> - Fast requests-based fetcher</li>\n<li><code>html_webdriver</code> - Browser-based fetcher (Playwright/Puppeteer)</li>\n<li><code>extra_browser_*</code> - Custom browser configurations (if configured)</li>\n<li>Plugin-provided fetchers (if installed)</li>\n</ul>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"headers\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand headers\"><span class=\"property-name\">headers</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;HTTP headers to include in requests&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP headers to include in requests</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"body\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">body</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;HTTP request body&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP request body</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"proxy\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">proxy</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Proxy configuration&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Proxy configuration</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignore_status_codes\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignore_status_codes</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Ignore HTTP status code errors (boolean or null)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Ignore HTTP status code errors (boolean or null)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"webdriver_delay\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">webdriver_delay</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or null</span></div> <div><div html=\"&lt;p&gt;Delay in seconds for webdriver&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Delay in seconds for webdriver</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"webdriver_js_execute_code\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">webdriver_js_execute_code</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;JavaScript code to execute&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>JavaScript code to execute</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_between_check\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand time_between_check\"><span class=\"property-name\">time_between_check</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_between_check_use_default\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">time_between_check_use_default</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Whether to use global settings for time between checks - defaults to true if not set&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether to use global settings for time between checks - defaults to true if not set</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_urls\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_urls</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 1000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Notification URLs for this web page change monitor (watch). Maximum 100 URLs.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Notification URLs for this web page change monitor (watch). Maximum 100 URLs.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_title\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_title</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom notification title&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom notification title</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_body\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_body</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom notification body&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom notification body</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_format\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_format</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;html&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;htmlcolor&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;markdown&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;System default&quot;</span> </div> <div><div html=\"&lt;p&gt;Format for notifications&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Format for notifications</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"track_ldjson_price_data\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">track_ldjson_price_data</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Whether to track JSON-LD price data&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether to track JSON-LD price data</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"browser_steps\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand browser_steps\"><span class=\"property-name\">browser_steps</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">objects</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Browser automation steps. Maximum 100 steps allowed.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Browser automation steps. Maximum 100 steps allowed.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"processor\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">processor</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text_json_diff&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;restock_diff&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text_json_diff&quot;</span> </div> <div><div html=\"&lt;p&gt;Optional processor mode to use for change detection. Defaults to &lt;code&gt;text_json_diff&lt;/code&gt; if not specified.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Optional processor mode to use for change detection. Defaults to <code>text_json_diff</code> if not specified.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"include_filters\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">include_filters</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;CSS/XPath selectors to extract specific content from the page&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>CSS/XPath selectors to extract specific content from the page</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"subtractive_selectors\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">subtractive_selectors</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;CSS/XPath selectors to remove content from the page&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>CSS/XPath selectors to remove content from the page</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignore_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignore_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text patterns to ignore in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text patterns to ignore in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"trigger_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">trigger_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text/regex patterns that must be present to trigger a change&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text/regex patterns that must be present to trigger a change</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"text_should_not_be_present\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">text_should_not_be_present</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text that should NOT be present (triggers alert if found)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text that should NOT be present (triggers alert if found)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"extract_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">extract_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Regex patterns to extract specific text after filtering&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Regex patterns to extract specific text after filtering</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"trim_text_whitespace\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">trim_text_whitespace</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Strip leading/trailing whitespace from text&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Strip leading/trailing whitespace from text</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"sort_text_alphabetically\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">sort_text_alphabetically</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Sort lines alphabetically before comparison&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Sort lines alphabetically before comparison</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"remove_duplicate_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">remove_duplicate_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Remove duplicate lines from content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Remove duplicate lines from content</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"check_unique_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">check_unique_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Compare against all history for unique lines&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Compare against all history for unique lines</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"strip_ignored_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">strip_ignored_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Remove lines matching ignore patterns&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Remove lines matching ignore patterns</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_added\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_added</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include added text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include added text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_removed\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_removed</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include removed text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include removed text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_replaced\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_replaced</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include replaced text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include replaced text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"in_stock_only\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">in_stock_only</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Only trigger on in-stock transitions (restock_diff processor)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Only trigger on in-stock transitions (restock_diff processor)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"follow_price_changes\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">follow_price_changes</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Monitor and track price changes (restock_diff processor)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Monitor and track price changes (restock_diff processor)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"price_change_threshold_percent\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">price_change_threshold_percent</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">number or null</span></div> <div><div html=\"&lt;p&gt;Minimum price change percentage to trigger notification&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Minimum price change percentage to trigger notification</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_screenshot\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_screenshot</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Include screenshot in notifications (if supported by notification URL)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include screenshot in notifications (if supported by notification URL)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_failure_notification_send\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_failure_notification_send</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Send notification when filters fail to match content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Send notification when filters fail to match content</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"use_page_title_in_list\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">use_page_title_in_list</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Display page title in watch list (null = use system default)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Display page title in watch list (null = use system default)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"history_snapshot_max_length\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">history_snapshot_max_length</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->[ 1 .. 1000 ]<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Maximum number of history snapshots to keep (null = use system default)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Maximum number of history snapshots to keep (null = use system default)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_schedule_limit\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand time_schedule_limit\"><span class=\"property-name\">time_schedule_limit</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;Weekly schedule limiting when checks can run&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Weekly schedule limiting when checks can run</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"conditions\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand conditions\"><span class=\"property-name\">conditions</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">objects</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Array of condition rules for change detection logic (empty array when not set)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Array of condition rules for change detection logic (empty array when not set)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"conditions_match_logic\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">conditions_match_logic</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ALL&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ALL&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ANY&quot;</span> </div> <div><div html=\"&lt;p&gt;Logic operator - ALL (match all conditions) or ANY (match any condition)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Logic operator - ALL (match all conditions) or ANY (match any condition)</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"last_viewed\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">last_viewed</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&gt;= 0<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Unix timestamp in seconds of the last time the watch was viewed. Setting it to a value higher than &lt;code&gt;last_changed&lt;/code&gt; in the &amp;quot;Update watch&amp;quot; endpoint marks the watch as viewed.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Unix timestamp in seconds of the last time the watch was viewed. Setting it to a value higher than <code>last_changed</code> in the &quot;Update watch&quot; endpoint marks the watch as viewed.</p>\n</div></div></div></td></tr></tbody></table><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Web page change monitor (watch) updated successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Web page change monitor (watch) updated successfully</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">500<!-- --> </strong><div html=\"&lt;p&gt;Server error&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Server error</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"put\" class=\"sc-fQLpxn dBzsUh http-verb put\">put</span><span class=\"sc-jvKoal kZcHWP\">/watch/{uuid}</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2acha_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2acha_0\" tabindex=\"0\" data-rttab=\"true\">Payload</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2acha_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2acha_1\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2acha_2\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2acha_2\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2acha_0\" aria-labelledby=\"tab_R_2acha_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"url\"</span>: <span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tag\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tags\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"paused\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"method\"</span>: <span class=\"token string\">&quot;GET&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"fetch_backend\"</span>: <span class=\"token string\">&quot;system&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"headers\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"property1\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"property2\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"body\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"proxy\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"ignore_status_codes\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"webdriver_delay\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"webdriver_js_execute_code\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"weeks\"</span>: <span class=\"token number\">52000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"days\"</span>: <span class=\"token number\">365000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token number\">8760000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token number\">525600000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"seconds\"</span>: <span class=\"token number\">31536000000</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check_use_default\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_body\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_format\"</span>: <span class=\"token string\">&quot;text&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"track_ldjson_price_data\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"browser_steps\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"operation\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"selector\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"optional_value\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"processor\"</span>: <span class=\"token string\">&quot;restock_diff&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"include_filters\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"subtractive_selectors\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"ignore_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"trigger_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"text_should_not_be_present\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"extract_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"trim_text_whitespace\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"sort_text_alphabetically\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"remove_duplicate_lines\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"check_unique_lines\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"strip_ignored_lines\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_added\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_removed\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_replaced\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"in_stock_only\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"follow_price_changes\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"price_change_threshold_percent\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_screenshot\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_failure_notification_send\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"use_page_title_in_list\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"history_snapshot_max_length\"</span>: <span class=\"token number\">1</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_schedule_limit\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"monday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"tuesday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"wednesday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"thursday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"friday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"saturday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"sunday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"conditions\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"field\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"operator\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"value\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"conditions_match_logic\"</span>: <span class=\"token string\">&quot;ALL&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"last_viewed\"</span>: <span class=\"token number\">0</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2acha_1\" aria-labelledby=\"tab_R_2acha_1\"></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2acha_2\" aria-labelledby=\"tab_R_2acha_2\"></div></div></div></div></div></div><div id=\"tag/Watch-Management/operation/deleteWatch\" data-section-id=\"tag/Watch-Management/operation/deleteWatch\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/deleteWatch\" id=\"operation/deleteWatch\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-Management/operation/deleteWatch\" aria-label=\"tag/Watch-Management/operation/deleteWatch\"></a>Delete watch<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Delete a web page change monitor (watch) and all related history&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Delete a web page change monitor (watch) and all related history</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Web page change monitor (watch) unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Web page change monitor (watch) unique ID</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Web page change monitor (watch) deleted successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Web page change monitor (watch) deleted successfully</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"delete\" class=\"sc-fQLpxn gKcHYQ http-verb delete\">delete</span><span class=\"sc-jvKoal kZcHWP\">/watch/{uuid}</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2adha_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2adha_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2adha_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2adha_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2adha_0\" aria-labelledby=\"tab_R_2adha_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X DELETE <span class=\"token string\">\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2adha_1\" aria-labelledby=\"tab_R_2adha_1\"></div></div></div></div></div></div><div id=\"tag/Watch-History\" data-section-id=\"tag/Watch-History\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-History\" aria-label=\"tag/Watch-History\"></a>Watch History</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Get a list of timestamps of all changes detected for a watch.&lt;/p&gt;\n\"><p>Get a list of timestamps of all changes detected for a watch.</p>\n</div></div></div><div id=\"tag/Watch-History/operation/getWatchHistory\" data-section-id=\"tag/Watch-History/operation/getWatchHistory\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getWatchHistory\" id=\"operation/getWatchHistory\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-History/operation/getWatchHistory\" aria-label=\"tag/Watch-History/operation/getWatchHistory\"></a>Get watch history<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Get a list of all historical snapshots available for a web page change monitor (watch), use the key &lt;code&gt;timestamp&lt;/code&gt;\nas the query argument for fetching a single watch history snapshot.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Get a list of all historical snapshots available for a web page change monitor (watch), use the key <code>timestamp</code>\nas the query argument for fetching a single watch history snapshot.</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Web page change monitor (watch) unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Web page change monitor (watch) unique ID</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;List of available snapshots&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>List of available snapshots</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">404<!-- --> </strong><div html=\"&lt;p&gt;Web page change monitor (watch) not found&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Web page change monitor (watch) not found</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/watch/{uuid}/history</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch/{uuid}/history</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch/{uuid}/history</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}/history</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_155hq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_155hq_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_155hq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_155hq_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_155hq_0\" aria-labelledby=\"tab_R_155hq_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/history\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_155hq_1\" aria-labelledby=\"tab_R_155hq_1\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_175hq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_175hq_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_175hq_0\" aria-labelledby=\"tab_R_175hq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"1640995200\"</span>: <span class=\"token string\">&quot;/path/to/snapshot1.txt&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"1640998800\"</span>: <span class=\"token string\">&quot;/path/to/snapshot2.txt&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Watch-History/operation/getWatchHistoryDiff\" data-section-id=\"tag/Watch-History/operation/getWatchHistoryDiff\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getWatchHistoryDiff\" id=\"operation/getWatchHistoryDiff\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Watch-History/operation/getWatchHistoryDiff\" aria-label=\"tag/Watch-History/operation/getWatchHistoryDiff\"></a>Get the difference between two snapshots<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Generate a difference (comparison) between two historical snapshots of a web page change monitor (watch).&lt;/p&gt;\n&lt;p&gt;This endpoint compares content between two points in time and returns the differences in your chosen format.\nPerfect for reviewing what changed between specific versions or comparing recent changes.&lt;/p&gt;\n&lt;p&gt;&lt;strong&gt;Timestamp Keywords:&lt;/strong&gt;&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Use &lt;code&gt;&amp;#39;latest&amp;#39;&lt;/code&gt; for the most recent snapshot (to_timestamp)&lt;/li&gt;\n&lt;li&gt;Use &lt;code&gt;&amp;#39;previous&amp;#39;&lt;/code&gt; for the second-most-recent snapshot (from_timestamp)&lt;/li&gt;\n&lt;li&gt;Or use specific Unix timestamps from the watch history&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;&lt;strong&gt;Format Options:&lt;/strong&gt;&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;text&lt;/code&gt; (default): Plain text with (removed) and (added) prefixes&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html&lt;/code&gt;: HTML format with (removed) and (added) text&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;htmlcolor&lt;/code&gt;: Rich HTML with colored highlights (green for additions, red for deletions)&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;&lt;strong&gt;Word-Level Diffing:&lt;/strong&gt;&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Enable word-level granularity with &lt;code&gt;word_diff=true&lt;/code&gt; for detailed inline comparisons&lt;/li&gt;\n&lt;li&gt;Disable with &lt;code&gt;word_diff=false&lt;/code&gt; for line-level comparisons only (default false/off, line-level mode by default)&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;&lt;strong&gt;Raw Diff Output:&lt;/strong&gt;&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Use &lt;code&gt;no_markup=true&lt;/code&gt; to get raw diff content without any formatting applied&lt;/li&gt;\n&lt;li&gt;Returns content with placeholders for opening/closing tags of changes&lt;/li&gt;\n&lt;li&gt;Allows you to implement your own custom colorisation or formatting&lt;/li&gt;\n&lt;li&gt;Skips all HTML color application and service tweaks (added text, html color tags, etc)&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Generate a difference (comparison) between two historical snapshots of a web page change monitor (watch).</p>\n<p>This endpoint compares content between two points in time and returns the differences in your chosen format.\nPerfect for reviewing what changed between specific versions or comparing recent changes.</p>\n<p><strong>Timestamp Keywords:</strong></p>\n<ul>\n<li>Use <code>&#39;latest&#39;</code> for the most recent snapshot (to_timestamp)</li>\n<li>Use <code>&#39;previous&#39;</code> for the second-most-recent snapshot (from_timestamp)</li>\n<li>Or use specific Unix timestamps from the watch history</li>\n</ul>\n<p><strong>Format Options:</strong></p>\n<ul>\n<li><code>text</code> (default): Plain text with (removed) and (added) prefixes</li>\n<li><code>html</code>: HTML format with (removed) and (added) text</li>\n<li><code>htmlcolor</code>: Rich HTML with colored highlights (green for additions, red for deletions)</li>\n</ul>\n<p><strong>Word-Level Diffing:</strong></p>\n<ul>\n<li>Enable word-level granularity with <code>word_diff=true</code> for detailed inline comparisons</li>\n<li>Disable with <code>word_diff=false</code> for line-level comparisons only (default false/off, line-level mode by default)</li>\n</ul>\n<p><strong>Raw Diff Output:</strong></p>\n<ul>\n<li>Use <code>no_markup=true</code> to get raw diff content without any formatting applied</li>\n<li>Returns content with placeholders for opening/closing tags of changes</li>\n<li>Allows you to implement your own custom colorisation or formatting</li>\n<li>Skips all HTML color application and service tweaks (added text, html color tags, etc)</li>\n</ul>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Web page change monitor (watch) unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Web page change monitor (watch) unique ID</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"from_timestamp\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand from_timestamp\"><span class=\"property-name\">from_timestamp</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or string</span></div> <div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Example:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">previous</span></div><div><div html=\"&lt;p&gt;Starting snapshot timestamp, &amp;#39;previous&amp;#39; for second-most-recent, or specific Unix timestamp&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Starting snapshot timestamp, &#39;previous&#39; for second-most-recent, or specific Unix timestamp</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"to_timestamp\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand to_timestamp\"><span class=\"property-name\">to_timestamp</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or string</span></div> <div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Example:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">latest</span></div><div><div html=\"&lt;p&gt;Ending snapshot timestamp, &amp;#39;latest&amp;#39; for most recent, or specific Unix timestamp&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Ending snapshot timestamp, &#39;latest&#39; for most recent, or specific Unix timestamp</p>\n</div></div></div></td></tr></tbody></table></div><div><h5 class=\"sc-eqYatC czjApA\">query<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"format\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">format</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;html&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;htmlcolor&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;markdown&quot;</span> </div> <div><div html=\"&lt;p&gt;Output format for the diff:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;text&lt;/code&gt; (default): Plain text with (removed) and (added) prefixes&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html&lt;/code&gt;: Basic HTML format&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;htmlcolor&lt;/code&gt;: Rich HTML with colored backgrounds (red for deletions, green for additions)&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;markdown&lt;/code&gt;: Markdown format with HTML rendering&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Output format for the diff:</p>\n<ul>\n<li><code>text</code> (default): Plain text with (removed) and (added) prefixes</li>\n<li><code>html</code>: Basic HTML format</li>\n<li><code>htmlcolor</code>: Rich HTML with colored backgrounds (red for deletions, green for additions)</li>\n<li><code>markdown</code>: Markdown format with HTML rendering</li>\n</ul>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"word_diff\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">word_diff</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;0&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;yes&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;no&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;on&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;off&quot;</span> </div> <div><div html=\"&lt;p&gt;Enable word-level diffing for more granular comparisons.\nWhen enabled, changes are highlighted at the word level rather than line level.\nDefault is false (line-level mode).\nAccepts: true, false, 1, 0, yes, no, on, off&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Enable word-level diffing for more granular comparisons.\nWhen enabled, changes are highlighted at the word level rather than line level.\nDefault is false (line-level mode).\nAccepts: true, false, 1, 0, yes, no, on, off</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"no_markup\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">no_markup</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;0&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;yes&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;no&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;on&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;off&quot;</span> </div> <div><div html=\"&lt;p&gt;When set to true, returns the raw diff content without any markup formatting.\nThe content will include placeholders for opening/closing tags of the changes,\nallowing you to implement your own custom colorisation or formatting.\nThis skips all HTML color application and service tweaks.\nAccepts: true, false, 1, 0, yes, no, on, off&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>When set to true, returns the raw diff content without any markup formatting.\nThe content will include placeholders for opening/closing tags of the changes,\nallowing you to implement your own custom colorisation or formatting.\nThis skips all HTML color application and service tweaks.\nAccepts: true, false, 1, 0, yes, no, on, off</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"type\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">type</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;diffLines&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;diffLines&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;diffWords&quot;</span> </div> <div><div html=\"&lt;p&gt;Diff granularity type:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;diffLines&lt;/code&gt; (default): Line-level comparison, showing which lines changed&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;diffWords&lt;/code&gt;: Word-level comparison, showing which words changed within lines&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;This parameter is an alternative to &lt;code&gt;word_diff&lt;/code&gt; for better alignment with the UI.\nIf both are specified, &lt;code&gt;type=diffWords&lt;/code&gt; will enable word-level diffing.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Diff granularity type:</p>\n<ul>\n<li><code>diffLines</code> (default): Line-level comparison, showing which lines changed</li>\n<li><code>diffWords</code>: Word-level comparison, showing which words changed within lines</li>\n</ul>\n<p>This parameter is an alternative to <code>word_diff</code> for better alignment with the UI.\nIf both are specified, <code>type=diffWords</code> will enable word-level diffing.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"changesOnly\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">changesOnly</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;0&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;yes&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;no&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;on&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;off&quot;</span> </div> <div><div html=\"&lt;p&gt;When enabled, only show lines/content that changed (no surrounding context).\nWhen disabled, include unchanged lines for context around changes.\nAccepts: true, false, 1, 0, yes, no, on, off&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>When enabled, only show lines/content that changed (no surrounding context).\nWhen disabled, include unchanged lines for context around changes.\nAccepts: true, false, 1, 0, yes, no, on, off</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignoreWhitespace\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignoreWhitespace</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;0&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;yes&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;no&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;on&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;off&quot;</span> </div> <div><div html=\"&lt;p&gt;When enabled, ignore whitespace-only changes (spaces, tabs, newlines).\nUseful for focusing on content changes and ignoring formatting differences.\nAccepts: true, false, 1, 0, yes, no, on, off&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>When enabled, ignore whitespace-only changes (spaces, tabs, newlines).\nUseful for focusing on content changes and ignoring formatting differences.\nAccepts: true, false, 1, 0, yes, no, on, off</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"removed\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">removed</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;0&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;yes&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;no&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;on&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;off&quot;</span> </div> <div><div html=\"&lt;p&gt;Include removed/deleted content in the diff output.\nWhen disabled, content that was deleted will not appear in the diff.\nAccepts: true, false, 1, 0, yes, no, on, off&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include removed/deleted content in the diff output.\nWhen disabled, content that was deleted will not appear in the diff.\nAccepts: true, false, 1, 0, yes, no, on, off</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"added\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">added</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;0&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;yes&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;no&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;on&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;off&quot;</span> </div> <div><div html=\"&lt;p&gt;Include added/new content in the diff output.\nWhen disabled, content that was added will not appear in the diff.\nAccepts: true, false, 1, 0, yes, no, on, off&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include added/new content in the diff output.\nWhen disabled, content that was added will not appear in the diff.\nAccepts: true, false, 1, 0, yes, no, on, off</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"replaced\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">replaced</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;false&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;0&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;yes&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;no&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;on&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;off&quot;</span> </div> <div><div html=\"&lt;p&gt;Include replaced/modified content in the diff output.\nWhen disabled, content that was modified (changed from one value to another) will not appear in the diff.\nAccepts: true, false, 1, 0, yes, no, on, off&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include replaced/modified content in the diff output.\nWhen disabled, content that was modified (changed from one value to another) will not appear in the diff.\nAccepts: true, false, 1, 0, yes, no, on, off</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Formatted diff between the two snapshots&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Formatted diff between the two snapshots</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">400<!-- --> </strong><div html=\"&lt;p&gt;Invalid format parameter or invalid request&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Invalid format parameter or invalid request</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">404<!-- --> </strong><div html=\"&lt;p&gt;Watch not found, timestamps not found, or insufficient history&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Watch not found, timestamps not found, or insufficient history</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/watch/{uuid}/difference/{from_timestamp}/{to_timestamp}</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch/{uuid}/difference/{from_timestamp}/{to_timestamp}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch/{uuid}/difference/{from_timestamp}/{to_timestamp}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}/difference/{from_timestamp}/{to_timestamp}</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_156hq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_156hq_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_156hq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_156hq_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_156hq_0\" aria-labelledby=\"tab_R_156hq_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\"># Compare previous snapshot to latest with colored HTML\ncurl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/previous/latest?format=htmlcolor\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n\n# Compare two specific timestamps <span class=\"token keyword\">in</span> plain text with word<span class=\"token operator\">-</span>level diff\ncurl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/1640995200/1640998800?format=text&amp;word_diff=true\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n\n# Show only additions <span class=\"token punctuation\">(</span>hide removed<span class=\"token operator\">/</span>replaced content<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> ignore whitespace\ncurl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/previous/latest?format=htmlcolor&amp;removed=false&amp;replaced=false&amp;ignoreWhitespace=true\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_156hq_1\" aria-labelledby=\"tab_R_156hq_1\"></div></div></div></div></div></div><div id=\"tag/Snapshots\" data-section-id=\"tag/Snapshots\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Snapshots\" aria-label=\"tag/Snapshots\"></a>Snapshots</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Retrieve individual text snapshot of monitored content according to the &lt;code&gt;timestamp&lt;/code&gt;. The text snapshot is the HTML\nto Text at page check time. &lt;/p&gt;\n&lt;p&gt;Set the query argument &lt;code&gt;html&lt;/code&gt; to any value to retrieve the last HTML fetched, the system only keeps the last two \n(2) HTML files fetched.&lt;/p&gt;\n&lt;p&gt;Use the Watch History API endpoint to get a list of timestamps to pass to this query.&lt;/p&gt;\n\"><p>Retrieve individual text snapshot of monitored content according to the <code>timestamp</code>. The text snapshot is the HTML\nto Text at page check time. </p>\n<p>Set the query argument <code>html</code> to any value to retrieve the last HTML fetched, the system only keeps the last two \n(2) HTML files fetched.</p>\n<p>Use the Watch History API endpoint to get a list of timestamps to pass to this query.</p>\n</div></div></div><div id=\"tag/Snapshots/operation/getWatchSnapshot\" data-section-id=\"tag/Snapshots/operation/getWatchSnapshot\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getWatchSnapshot\" id=\"operation/getWatchSnapshot\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Snapshots/operation/getWatchSnapshot\" aria-label=\"tag/Snapshots/operation/getWatchSnapshot\"></a>Get single snapshot<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Get single snapshot from web page change monitor (watch). Use &amp;#39;latest&amp;#39; for the most recent snapshot.\nUse the Watch History API to get a list of timestamps to pass.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Get single snapshot from web page change monitor (watch). Use &#39;latest&#39; for the most recent snapshot.\nUse the Watch History API to get a list of timestamps to pass.</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Web page change monitor (watch) unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Web page change monitor (watch) unique ID</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"timestamp\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand timestamp\"><span class=\"property-name\">timestamp</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or string</span></div> <div><div html=\"&lt;p&gt;Snapshot timestamp or &amp;#39;latest&amp;#39;&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Snapshot timestamp or &#39;latest&#39;</p>\n</div></div></div></td></tr></tbody></table></div><div><h5 class=\"sc-eqYatC czjApA\">query<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"html\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">html</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Value<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;1&quot;</span> </div> <div><div html=\"&lt;p&gt;Set to 1 to return the last HTML&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Set to 1 to return the last HTML</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Snapshot content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Snapshot content</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">404<!-- --> </strong><div html=\"&lt;p&gt;Snapshot not found&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Snapshot not found</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/watch/{uuid}/history/{timestamp}</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch/{uuid}/history/{timestamp}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch/{uuid}/history/{timestamp}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}/history/{timestamp}</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_ijia_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_ijia_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_ijia_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_ijia_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_ijia_0\" aria-labelledby=\"tab_R_ijia_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/history/latest\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_ijia_1\" aria-labelledby=\"tab_R_ijia_1\"></div></div></div></div></div></div><div id=\"tag/Favicon\" data-section-id=\"tag/Favicon\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Favicon\" aria-label=\"tag/Favicon\"></a>Favicon</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Retrieve favicon images associated with monitored web pages. These are used in the dashboard interface \nto visually identify different watches in your monitoring list.&lt;/p&gt;\n\"><p>Retrieve favicon images associated with monitored web pages. These are used in the dashboard interface \nto visually identify different watches in your monitoring list.</p>\n</div></div></div><div id=\"tag/Favicon/operation/getWatchFavicon\" data-section-id=\"tag/Favicon/operation/getWatchFavicon\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getWatchFavicon\" id=\"operation/getWatchFavicon\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Favicon/operation/getWatchFavicon\" aria-label=\"tag/Favicon/operation/getWatchFavicon\"></a>Get watch favicon<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Get the favicon for a web page change monitor (watch) as displayed in the watch overview list.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Get the favicon for a web page change monitor (watch) as displayed in the watch overview list.</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Web page change monitor (watch) unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Web page change monitor (watch) unique ID</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Favicon binary data&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Favicon binary data</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">404<!-- --> </strong><div html=\"&lt;p&gt;Favicon not found&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Favicon not found</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/watch/{uuid}/favicon</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/watch/{uuid}/favicon</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/watch/{uuid}/favicon</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}/favicon</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_ijiq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_ijiq_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_ijiq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_ijiq_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_ijiq_0\" aria-labelledby=\"tab_R_ijiq_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/favicon\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span> \\\n  <span class=\"token operator\">--</span>output favicon<span class=\"token punctuation\">.</span>ico\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_ijiq_1\" aria-labelledby=\"tab_R_ijiq_1\"></div></div></div></div></div></div><div id=\"tag/Group-Tag-Management\" data-section-id=\"tag/Group-Tag-Management\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Group-Tag-Management\" aria-label=\"tag/Group-Tag-Management\"></a>Group / Tag Management</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Organize your watches using tags and groups. Tags (also known as Groups) allow you to categorize monitors, set group-wide \nnotification preferences, and perform bulk operations like mass rechecking or status changes across \nmultiple related watches.&lt;/p&gt;\n\"><p>Organize your watches using tags and groups. Tags (also known as Groups) allow you to categorize monitors, set group-wide \nnotification preferences, and perform bulk operations like mass rechecking or status changes across \nmultiple related watches.</p>\n</div></div></div><div id=\"tag/Group-Tag-Management/operation/listTags\" data-section-id=\"tag/Group-Tag-Management/operation/listTags\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/listTags\" id=\"operation/listTags\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Group-Tag-Management/operation/listTags\" aria-label=\"tag/Group-Tag-Management/operation/listTags\"></a>List all tags<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Return list of available tags/groups&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Return list of available tags/groups</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;List of tags&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>List of tags</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/tags</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/tags</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/tags</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/tags</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2a9ja_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2a9ja_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2a9ja_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2a9ja_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2a9ja_0\" aria-labelledby=\"tab_R_2a9ja_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/tags\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2a9ja_1\" aria-labelledby=\"tab_R_2a9ja_1\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2e9ja_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2e9ja_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2e9ja_0\" aria-labelledby=\"tab_R_2e9ja_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"550e8400-e29b-41d4-a716-446655440000\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"uuid\"</span>: <span class=\"token string\">&quot;550e8400-e29b-41d4-a716-446655440000&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;Production Sites&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;mailto:admin@example.com&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">false</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"330e8400-e29b-41d4-a716-446655440001\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"uuid\"</span>: <span class=\"token string\">&quot;330e8400-e29b-41d4-a716-446655440001&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;News Sources&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;discord://webhook_id/webhook_token&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">false</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Group-Tag-Management/operation/createTag\" data-section-id=\"tag/Group-Tag-Management/operation/createTag\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/createTag\" id=\"operation/createTag\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Group-Tag-Management/operation/createTag\" aria-label=\"tag/Group-Tag-Management/operation/createTag\"></a>Create tag<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Create a single tag/group&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Create a single tag/group</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><h5 class=\"sc-eqYatC czjApA\">Request Body schema: <span class=\"sc-dNFkOE cFlAeY\">application/json</span><div class=\"sc-bEjUoa sc-iIvHqT sc-eTCgfj lhyyLL crXmiY foplsk\">required</div></h5><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"url\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">url</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uri<!-- -->&gt;<!-- --> </span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;URL to monitor for changes&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>URL to monitor for changes</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"title\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">title</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom title for the web page change monitor (watch), not to be confused with page_title&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom title for the web page change monitor (watch), not to be confused with page_title</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tag\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tag</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Tag UUID to associate with this web page change monitor (watch)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag UUID to associate with this web page change monitor (watch)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tags\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tags</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span></div> <div><div html=\"&lt;p&gt;Array of tag UUIDs&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Array of tag UUIDs</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"paused\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">paused</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div> <div><div html=\"&lt;p&gt;Whether the web page change monitor (watch) is paused&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether the web page change monitor (watch) is paused</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_muted\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_muted</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div> <div><div html=\"&lt;p&gt;Whether notifications are muted&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether notifications are muted</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"method\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">method</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;GET&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;POST&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;DELETE&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;PUT&quot;</span> </div> <div><div html=\"&lt;p&gt;HTTP method to use&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP method to use</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"fetch_backend\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">fetch_backend</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-cpclqO lhyyLL UZcrz\">^(system|html_requests|html_webdriver|extra_b...</span><button class=\"sc-gSifMm jBrfIx\">Show pattern</button></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;system&quot;</span></div> <div><div html=\"&lt;p&gt;Backend to use for fetching content. Common values:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;system&lt;/code&gt; (default) - Use the system-wide default fetcher&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html_requests&lt;/code&gt; - Fast requests-based fetcher&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html_webdriver&lt;/code&gt; - Browser-based fetcher (Playwright/Puppeteer)&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;extra_browser_*&lt;/code&gt; - Custom browser configurations (if configured)&lt;/li&gt;\n&lt;li&gt;Plugin-provided fetchers (if installed)&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Backend to use for fetching content. Common values:</p>\n<ul>\n<li><code>system</code> (default) - Use the system-wide default fetcher</li>\n<li><code>html_requests</code> - Fast requests-based fetcher</li>\n<li><code>html_webdriver</code> - Browser-based fetcher (Playwright/Puppeteer)</li>\n<li><code>extra_browser_*</code> - Custom browser configurations (if configured)</li>\n<li>Plugin-provided fetchers (if installed)</li>\n</ul>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"headers\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand headers\"><span class=\"property-name\">headers</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;HTTP headers to include in requests&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP headers to include in requests</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"body\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">body</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;HTTP request body&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP request body</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"proxy\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">proxy</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Proxy configuration&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Proxy configuration</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignore_status_codes\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignore_status_codes</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Ignore HTTP status code errors (boolean or null)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Ignore HTTP status code errors (boolean or null)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"webdriver_delay\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">webdriver_delay</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or null</span></div> <div><div html=\"&lt;p&gt;Delay in seconds for webdriver&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Delay in seconds for webdriver</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"webdriver_js_execute_code\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">webdriver_js_execute_code</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;JavaScript code to execute&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>JavaScript code to execute</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_between_check\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand time_between_check\"><span class=\"property-name\">time_between_check</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_between_check_use_default\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">time_between_check_use_default</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Whether to use global settings for time between checks - defaults to true if not set&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether to use global settings for time between checks - defaults to true if not set</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_urls\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_urls</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 1000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Notification URLs for this web page change monitor (watch). Maximum 100 URLs.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Notification URLs for this web page change monitor (watch). Maximum 100 URLs.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_title\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_title</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom notification title&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom notification title</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_body\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_body</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom notification body&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom notification body</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_format\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_format</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;html&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;htmlcolor&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;markdown&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;System default&quot;</span> </div> <div><div html=\"&lt;p&gt;Format for notifications&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Format for notifications</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"track_ldjson_price_data\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">track_ldjson_price_data</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Whether to track JSON-LD price data&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether to track JSON-LD price data</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"browser_steps\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand browser_steps\"><span class=\"property-name\">browser_steps</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">objects</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Browser automation steps. Maximum 100 steps allowed.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Browser automation steps. Maximum 100 steps allowed.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"processor\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">processor</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text_json_diff&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;restock_diff&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text_json_diff&quot;</span> </div> <div><div html=\"&lt;p&gt;Optional processor mode to use for change detection. Defaults to &lt;code&gt;text_json_diff&lt;/code&gt; if not specified.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Optional processor mode to use for change detection. Defaults to <code>text_json_diff</code> if not specified.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"include_filters\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">include_filters</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;CSS/XPath selectors to extract specific content from the page&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>CSS/XPath selectors to extract specific content from the page</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"subtractive_selectors\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">subtractive_selectors</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;CSS/XPath selectors to remove content from the page&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>CSS/XPath selectors to remove content from the page</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignore_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignore_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text patterns to ignore in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text patterns to ignore in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"trigger_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">trigger_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text/regex patterns that must be present to trigger a change&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text/regex patterns that must be present to trigger a change</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"text_should_not_be_present\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">text_should_not_be_present</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text that should NOT be present (triggers alert if found)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text that should NOT be present (triggers alert if found)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"extract_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">extract_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Regex patterns to extract specific text after filtering&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Regex patterns to extract specific text after filtering</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"trim_text_whitespace\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">trim_text_whitespace</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Strip leading/trailing whitespace from text&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Strip leading/trailing whitespace from text</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"sort_text_alphabetically\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">sort_text_alphabetically</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Sort lines alphabetically before comparison&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Sort lines alphabetically before comparison</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"remove_duplicate_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">remove_duplicate_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Remove duplicate lines from content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Remove duplicate lines from content</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"check_unique_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">check_unique_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Compare against all history for unique lines&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Compare against all history for unique lines</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"strip_ignored_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">strip_ignored_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Remove lines matching ignore patterns&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Remove lines matching ignore patterns</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_added\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_added</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include added text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include added text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_removed\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_removed</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include removed text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include removed text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_replaced\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_replaced</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include replaced text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include replaced text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"in_stock_only\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">in_stock_only</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Only trigger on in-stock transitions (restock_diff processor)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Only trigger on in-stock transitions (restock_diff processor)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"follow_price_changes\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">follow_price_changes</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Monitor and track price changes (restock_diff processor)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Monitor and track price changes (restock_diff processor)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"price_change_threshold_percent\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">price_change_threshold_percent</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">number or null</span></div> <div><div html=\"&lt;p&gt;Minimum price change percentage to trigger notification&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Minimum price change percentage to trigger notification</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_screenshot\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_screenshot</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Include screenshot in notifications (if supported by notification URL)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include screenshot in notifications (if supported by notification URL)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_failure_notification_send\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_failure_notification_send</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Send notification when filters fail to match content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Send notification when filters fail to match content</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"use_page_title_in_list\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">use_page_title_in_list</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Display page title in watch list (null = use system default)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Display page title in watch list (null = use system default)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"history_snapshot_max_length\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">history_snapshot_max_length</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->[ 1 .. 1000 ]<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Maximum number of history snapshots to keep (null = use system default)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Maximum number of history snapshots to keep (null = use system default)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_schedule_limit\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand time_schedule_limit\"><span class=\"property-name\">time_schedule_limit</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;Weekly schedule limiting when checks can run&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Weekly schedule limiting when checks can run</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"conditions\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand conditions\"><span class=\"property-name\">conditions</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">objects</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Array of condition rules for change detection logic (empty array when not set)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Array of condition rules for change detection logic (empty array when not set)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"conditions_match_logic\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">conditions_match_logic</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ALL&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ALL&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ANY&quot;</span> </div> <div><div html=\"&lt;p&gt;Logic operator - ALL (match all conditions) or ANY (match any condition)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Logic operator - ALL (match all conditions) or ANY (match any condition)</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"overrides_watch\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">overrides_watch</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Whether this tag&amp;#39;s settings override watch settings for all watches in this tag/group.&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;true: Tag settings override watch settings&lt;/li&gt;\n&lt;li&gt;false: Tag settings do not override (watches use their own settings)&lt;/li&gt;\n&lt;li&gt;null: Not decided yet / inherit default behavior&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether this tag&#39;s settings override watch settings for all watches in this tag/group.</p>\n<ul>\n<li>true: Tag settings override watch settings</li>\n<li>false: Tag settings do not override (watches use their own settings)</li>\n<li>null: Not decided yet / inherit default behavior</li>\n</ul>\n</div></div></div></td></tr></tbody></table><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">201<!-- --> </strong><div html=\"&lt;p&gt;Tag created successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Tag created successfully</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">400<!-- --> </strong><div html=\"&lt;p&gt;Invalid or unsupported tag&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Invalid or unsupported tag</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"post\" class=\"sc-fQLpxn kwcmyC http-verb post\">post</span><span class=\"sc-jvKoal kZcHWP\">/tag</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/tag</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/tag</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/tag</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2aaja_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2aaja_0\" tabindex=\"0\" data-rttab=\"true\">Payload</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2aaja_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2aaja_1\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2aaja_2\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2aaja_2\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2aaja_0\" aria-labelledby=\"tab_R_2aaja_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;Important Sites&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2aaja_1\" aria-labelledby=\"tab_R_2aaja_1\"></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2aaja_2\" aria-labelledby=\"tab_R_2aaja_2\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2eaja_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2eaja_0\" tabindex=\"0\" data-rttab=\"true\">201</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2eaja_0\" aria-labelledby=\"tab_R_2eaja_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"uuid\"</span>: <span class=\"token string\">&quot;095be615-a8ad-4c33-8e9c-c7612fbf6c9f&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Group-Tag-Management/operation/getTag\" data-section-id=\"tag/Group-Tag-Management/operation/getTag\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getTag\" id=\"operation/getTag\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Group-Tag-Management/operation/getTag\" aria-label=\"tag/Group-Tag-Management/operation/getTag\"></a>Get single tag<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Retrieve tag information, set notification_muted status, recheck all web page change monitors (watches) in tag.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Retrieve tag information, set notification_muted status, recheck all web page change monitors (watches) in tag.</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Tag unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag unique ID</p>\n</div></div></div></td></tr></tbody></table></div><div><h5 class=\"sc-eqYatC czjApA\">query<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"muted\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">muted</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;muted&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;unmuted&quot;</span> </div> <div><div html=\"&lt;p&gt;Set mute state&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Set mute state</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"recheck\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">recheck</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Value<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;true&quot;</span> </div> <div><div html=\"&lt;p&gt;Queue all web page change monitors (watches) with this tag for recheck&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Queue all web page change monitors (watches) with this tag for recheck</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Tag information or operation result&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Tag information or operation result</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">404<!-- --> </strong><div html=\"&lt;p&gt;Tag not found&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Tag not found</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/tag/{uuid}</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/tag/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/tag/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/tag/{uuid}</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2abja_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2abja_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2abja_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2abja_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2abja_0\" aria-labelledby=\"tab_R_2abja_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2abja_1\" aria-labelledby=\"tab_R_2abja_1\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2ebja_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2ebja_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2ebja_0\" aria-labelledby=\"tab_R_2ebja_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-cCVJLD sc-gsJsQu dbfEBv ehbHlf\"><svg class=\"sc-pYNGo eyTvTk\" xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg><select class=\"dropdown-select\"><option value=\"application/json\" selected=\"\">application/json</option><option value=\"text/plain\">text/plain</option></select><label>application/json</label></div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"uuid\"</span>: <span class=\"token string\">&quot;095be615-a8ad-4c33-8e9c-c7612fbf6c9f&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"date_created\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"url\"</span>: <span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tag\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tags\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"paused\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"method\"</span>: <span class=\"token string\">&quot;GET&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"fetch_backend\"</span>: <span class=\"token string\">&quot;system&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"headers\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"property1\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"property2\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"body\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"proxy\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"ignore_status_codes\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"webdriver_delay\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"webdriver_js_execute_code\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"weeks\"</span>: <span class=\"token number\">52000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"days\"</span>: <span class=\"token number\">365000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token number\">8760000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token number\">525600000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"seconds\"</span>: <span class=\"token number\">31536000000</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check_use_default\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_body\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_format\"</span>: <span class=\"token string\">&quot;text&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"track_ldjson_price_data\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"browser_steps\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"operation\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"selector\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"optional_value\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"processor\"</span>: <span class=\"token string\">&quot;restock_diff&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"include_filters\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"subtractive_selectors\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"ignore_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"trigger_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"text_should_not_be_present\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"extract_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"trim_text_whitespace\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"sort_text_alphabetically\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"remove_duplicate_lines\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"check_unique_lines\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"strip_ignored_lines\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_added\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_removed\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_replaced\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"in_stock_only\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"follow_price_changes\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"price_change_threshold_percent\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"has_ldjson_price_data\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_screenshot\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_failure_notification_send\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"use_page_title_in_list\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"history_snapshot_max_length\"</span>: <span class=\"token number\">1</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_schedule_limit\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"monday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"tuesday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"wednesday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"thursday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"friday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"saturday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"sunday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"conditions\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"field\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"operator\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"value\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"conditions_match_logic\"</span>: <span class=\"token string\">&quot;ALL&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"overrides_watch\"</span>: <span class=\"token boolean\">true</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Group-Tag-Management/operation/updateTag\" data-section-id=\"tag/Group-Tag-Management/operation/updateTag\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/updateTag\" id=\"operation/updateTag\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Group-Tag-Management/operation/updateTag\" aria-label=\"tag/Group-Tag-Management/operation/updateTag\"></a>Update tag<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Update an existing tag using JSON&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Update an existing tag using JSON</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Tag unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag unique ID</p>\n</div></div></div></td></tr></tbody></table></div><h5 class=\"sc-eqYatC czjApA\">Request Body schema: <span class=\"sc-dNFkOE cFlAeY\">application/json</span><div class=\"sc-bEjUoa sc-iIvHqT sc-eTCgfj lhyyLL crXmiY foplsk\">required</div></h5><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"url\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">url</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uri<!-- -->&gt;<!-- --> </span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;URL to monitor for changes&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>URL to monitor for changes</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"title\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">title</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom title for the web page change monitor (watch), not to be confused with page_title&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom title for the web page change monitor (watch), not to be confused with page_title</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tag\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tag</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Tag UUID to associate with this web page change monitor (watch)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag UUID to associate with this web page change monitor (watch)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tags\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tags</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span></div> <div><div html=\"&lt;p&gt;Array of tag UUIDs&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Array of tag UUIDs</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"paused\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">paused</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div> <div><div html=\"&lt;p&gt;Whether the web page change monitor (watch) is paused&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether the web page change monitor (watch) is paused</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_muted\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_muted</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div> <div><div html=\"&lt;p&gt;Whether notifications are muted&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether notifications are muted</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"method\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">method</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;GET&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;POST&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;DELETE&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;PUT&quot;</span> </div> <div><div html=\"&lt;p&gt;HTTP method to use&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP method to use</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"fetch_backend\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">fetch_backend</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-cpclqO lhyyLL UZcrz\">^(system|html_requests|html_webdriver|extra_b...</span><button class=\"sc-gSifMm jBrfIx\">Show pattern</button></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;system&quot;</span></div> <div><div html=\"&lt;p&gt;Backend to use for fetching content. Common values:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;system&lt;/code&gt; (default) - Use the system-wide default fetcher&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html_requests&lt;/code&gt; - Fast requests-based fetcher&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;html_webdriver&lt;/code&gt; - Browser-based fetcher (Playwright/Puppeteer)&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;extra_browser_*&lt;/code&gt; - Custom browser configurations (if configured)&lt;/li&gt;\n&lt;li&gt;Plugin-provided fetchers (if installed)&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Backend to use for fetching content. Common values:</p>\n<ul>\n<li><code>system</code> (default) - Use the system-wide default fetcher</li>\n<li><code>html_requests</code> - Fast requests-based fetcher</li>\n<li><code>html_webdriver</code> - Browser-based fetcher (Playwright/Puppeteer)</li>\n<li><code>extra_browser_*</code> - Custom browser configurations (if configured)</li>\n<li>Plugin-provided fetchers (if installed)</li>\n</ul>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"headers\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand headers\"><span class=\"property-name\">headers</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;HTTP headers to include in requests&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP headers to include in requests</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"body\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">body</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;HTTP request body&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>HTTP request body</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"proxy\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">proxy</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Proxy configuration&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Proxy configuration</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignore_status_codes\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignore_status_codes</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Ignore HTTP status code errors (boolean or null)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Ignore HTTP status code errors (boolean or null)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"webdriver_delay\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">webdriver_delay</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or null</span></div> <div><div html=\"&lt;p&gt;Delay in seconds for webdriver&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Delay in seconds for webdriver</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"webdriver_js_execute_code\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">webdriver_js_execute_code</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;JavaScript code to execute&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>JavaScript code to execute</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_between_check\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand time_between_check\"><span class=\"property-name\">time_between_check</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_between_check_use_default\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">time_between_check_use_default</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Whether to use global settings for time between checks - defaults to true if not set&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether to use global settings for time between checks - defaults to true if not set</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_urls\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_urls</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 1000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Notification URLs for this web page change monitor (watch). Maximum 100 URLs.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Notification URLs for this web page change monitor (watch). Maximum 100 URLs.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_title\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_title</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom notification title&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom notification title</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_body\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_body</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Custom notification body&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom notification body</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_format\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_format</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;html&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;htmlcolor&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;markdown&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;System default&quot;</span> </div> <div><div html=\"&lt;p&gt;Format for notifications&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Format for notifications</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"track_ldjson_price_data\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">track_ldjson_price_data</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Whether to track JSON-LD price data&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether to track JSON-LD price data</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"browser_steps\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand browser_steps\"><span class=\"property-name\">browser_steps</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">objects</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Browser automation steps. Maximum 100 steps allowed.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Browser automation steps. Maximum 100 steps allowed.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"processor\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">processor</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text_json_diff&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;restock_diff&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;text_json_diff&quot;</span> </div> <div><div html=\"&lt;p&gt;Optional processor mode to use for change detection. Defaults to &lt;code&gt;text_json_diff&lt;/code&gt; if not specified.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Optional processor mode to use for change detection. Defaults to <code>text_json_diff</code> if not specified.</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"include_filters\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">include_filters</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;CSS/XPath selectors to extract specific content from the page&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>CSS/XPath selectors to extract specific content from the page</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"subtractive_selectors\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">subtractive_selectors</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;CSS/XPath selectors to remove content from the page&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>CSS/XPath selectors to remove content from the page</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"ignore_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">ignore_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text patterns to ignore in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text patterns to ignore in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"trigger_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">trigger_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text/regex patterns that must be present to trigger a change&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text/regex patterns that must be present to trigger a change</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"text_should_not_be_present\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">text_should_not_be_present</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Text that should NOT be present (triggers alert if found)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Text that should NOT be present (triggers alert if found)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"extract_text\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">extract_text</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 5000 characters<!-- --> </span></span> ]</span></div> <div><div html=\"&lt;p&gt;Regex patterns to extract specific text after filtering&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Regex patterns to extract specific text after filtering</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"trim_text_whitespace\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">trim_text_whitespace</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Strip leading/trailing whitespace from text&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Strip leading/trailing whitespace from text</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"sort_text_alphabetically\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">sort_text_alphabetically</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Sort lines alphabetically before comparison&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Sort lines alphabetically before comparison</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"remove_duplicate_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">remove_duplicate_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Remove duplicate lines from content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Remove duplicate lines from content</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"check_unique_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">check_unique_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Compare against all history for unique lines&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Compare against all history for unique lines</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"strip_ignored_lines\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">strip_ignored_lines</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Remove lines matching ignore patterns&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Remove lines matching ignore patterns</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_added\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_added</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include added text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include added text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_removed\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_removed</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include removed text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include removed text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_text_replaced\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_text_replaced</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Include replaced text in change detection&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include replaced text in change detection</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"in_stock_only\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">in_stock_only</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Only trigger on in-stock transitions (restock_diff processor)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Only trigger on in-stock transitions (restock_diff processor)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"follow_price_changes\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">follow_price_changes</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Monitor and track price changes (restock_diff processor)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Monitor and track price changes (restock_diff processor)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"price_change_threshold_percent\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">price_change_threshold_percent</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">number or null</span></div> <div><div html=\"&lt;p&gt;Minimum price change percentage to trigger notification&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Minimum price change percentage to trigger notification</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"notification_screenshot\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_screenshot</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">false</span></div> <div><div html=\"&lt;p&gt;Include screenshot in notifications (if supported by notification URL)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Include screenshot in notifications (if supported by notification URL)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"filter_failure_notification_send\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">filter_failure_notification_send</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Send notification when filters fail to match content&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Send notification when filters fail to match content</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"use_page_title_in_list\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">use_page_title_in_list</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Display page title in watch list (null = use system default)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Display page title in watch list (null = use system default)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"history_snapshot_max_length\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">history_snapshot_max_length</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">integer or null</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->[ 1 .. 1000 ]<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Maximum number of history snapshots to keep (null = use system default)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Maximum number of history snapshots to keep (null = use system default)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"time_schedule_limit\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand time_schedule_limit\"><span class=\"property-name\">time_schedule_limit</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">object</span></div> <div><div html=\"&lt;p&gt;Weekly schedule limiting when checks can run&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Weekly schedule limiting when checks can run</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"conditions\" class=\"sc-kCuUfV sc-fbQrwq sc-itBLYH gdmNWp dFOJWJ kdPQHX\"><span class=\"sc-hwddKA cteAyA\"></span><button aria-label=\"expand conditions\"><span class=\"property-name\">conditions</span><svg class=\"sc-dntSTA dOPmTa\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">objects</span><span> <span class=\"sc-bEjUoa sc-goiVcJ lhyyLL bDfgbe\"> <!-- -->&lt;= 100 items<!-- --> </span></span></div> <div><div html=\"&lt;p&gt;Array of condition rules for change detection logic (empty array when not set)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Array of condition rules for change detection logic (empty array when not set)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"conditions_match_logic\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">conditions_match_logic</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ALL&quot;</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Enum<!-- -->:</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ALL&quot;</span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">&quot;ANY&quot;</span> </div> <div><div html=\"&lt;p&gt;Logic operator - ALL (match all conditions) or ANY (match any condition)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Logic operator - ALL (match all conditions) or ANY (match any condition)</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"overrides_watch\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">overrides_watch</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean or null</span></div> <div><div html=\"&lt;p&gt;Whether this tag&amp;#39;s settings override watch settings for all watches in this tag/group.&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;true: Tag settings override watch settings&lt;/li&gt;\n&lt;li&gt;false: Tag settings do not override (watches use their own settings)&lt;/li&gt;\n&lt;li&gt;null: Not decided yet / inherit default behavior&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Whether this tag&#39;s settings override watch settings for all watches in this tag/group.</p>\n<ul>\n<li>true: Tag settings override watch settings</li>\n<li>false: Tag settings do not override (watches use their own settings)</li>\n<li>null: Not decided yet / inherit default behavior</li>\n</ul>\n</div></div></div></td></tr></tbody></table><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd oZuve\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Tag updated successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Tag updated successfully</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">500<!-- --> </strong><div html=\"&lt;p&gt;Server error&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Server error</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"put\" class=\"sc-fQLpxn dBzsUh http-verb put\">put</span><span class=\"sc-jvKoal kZcHWP\">/tag/{uuid}</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/tag/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/tag/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/tag/{uuid}</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2acja_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2acja_0\" tabindex=\"0\" data-rttab=\"true\">Payload</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2acja_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2acja_1\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2acja_2\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2acja_2\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2acja_0\" aria-labelledby=\"tab_R_2acja_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"url\"</span>: <span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tag\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tags\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"paused\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"method\"</span>: <span class=\"token string\">&quot;GET&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"fetch_backend\"</span>: <span class=\"token string\">&quot;system&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"headers\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"property1\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"property2\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"body\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"proxy\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"ignore_status_codes\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"webdriver_delay\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"webdriver_js_execute_code\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"weeks\"</span>: <span class=\"token number\">52000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"days\"</span>: <span class=\"token number\">365000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token number\">8760000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token number\">525600000</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"seconds\"</span>: <span class=\"token number\">31536000000</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_between_check_use_default\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_title\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_body\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_format\"</span>: <span class=\"token string\">&quot;text&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"track_ldjson_price_data\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"browser_steps\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"operation\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"selector\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"optional_value\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"processor\"</span>: <span class=\"token string\">&quot;restock_diff&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"include_filters\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"subtractive_selectors\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"ignore_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"trigger_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"text_should_not_be_present\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"extract_text\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"trim_text_whitespace\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"sort_text_alphabetically\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"remove_duplicate_lines\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"check_unique_lines\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"strip_ignored_lines\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_added\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_removed\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_text_replaced\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"in_stock_only\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"follow_price_changes\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"price_change_threshold_percent\"</span>: <span class=\"token number\">0</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_screenshot\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"filter_failure_notification_send\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"use_page_title_in_list\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"history_snapshot_max_length\"</span>: <span class=\"token number\">1</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"time_schedule_limit\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"monday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"tuesday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"wednesday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"thursday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"friday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"saturday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"sunday\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"enabled\"</span>: <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"start_time\"</span>: <span class=\"token string\">&quot;00:00&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"duration\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"hours\"</span>: <span class=\"token string\">&quot;24&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"minutes\"</span>: <span class=\"token string\">&quot;00&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"conditions\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"field\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"operator\"</span>: <span class=\"token string\">&quot;string&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"value\"</span>: <span class=\"token string\">&quot;string&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"conditions_match_logic\"</span>: <span class=\"token string\">&quot;ALL&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"overrides_watch\"</span>: <span class=\"token boolean\">true</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2acja_1\" aria-labelledby=\"tab_R_2acja_1\"></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2acja_2\" aria-labelledby=\"tab_R_2acja_2\"></div></div></div></div></div></div><div id=\"tag/Group-Tag-Management/operation/deleteTag\" data-section-id=\"tag/Group-Tag-Management/operation/deleteTag\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/deleteTag\" id=\"operation/deleteTag\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Group-Tag-Management/operation/deleteTag\" aria-label=\"tag/Group-Tag-Management/operation/deleteTag\"></a>Delete tag<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Delete a tag/group and remove it from all web page change monitors (watches)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Delete a tag/group and remove it from all web page change monitors (watches)</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">path<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"uuid\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">uuid</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uuid<!-- -->&gt;<!-- --> </span></div> <div><div html=\"&lt;p&gt;Tag unique ID&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag unique ID</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd oZuve\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Tag deleted successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Tag deleted successfully</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"delete\" class=\"sc-fQLpxn gKcHYQ http-verb delete\">delete</span><span class=\"sc-jvKoal kZcHWP\">/tag/{uuid}</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/tag/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/tag/{uuid}</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/tag/{uuid}</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2adja_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2adja_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2adja_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2adja_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2adja_0\" aria-labelledby=\"tab_R_2adja_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X DELETE <span class=\"token string\">\"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2adja_1\" aria-labelledby=\"tab_R_2adja_1\"></div></div></div></div></div></div><div id=\"tag/Notifications\" data-section-id=\"tag/Notifications\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Notifications\" aria-label=\"tag/Notifications\"></a>Notifications</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Configure global notification endpoints that can be used across all your watches. Supports various \nnotification services including email, Discord, Slack, webhooks, and many other popular platforms. \nThese settings serve as defaults that can be overridden at the individual watch or tag level.&lt;/p&gt;\n&lt;p&gt;The notification syntax uses &lt;a href=&quot;https://github.com/caronc/apprise&quot;&gt;https://github.com/caronc/apprise&lt;/a&gt;.&lt;/p&gt;\n\"><p>Configure global notification endpoints that can be used across all your watches. Supports various \nnotification services including email, Discord, Slack, webhooks, and many other popular platforms. \nThese settings serve as defaults that can be overridden at the individual watch or tag level.</p>\n<p>The notification syntax uses <a href=\"https://github.com/caronc/apprise\">https://github.com/caronc/apprise</a>.</p>\n</div></div></div><div id=\"tag/Notifications/operation/getNotifications\" data-section-id=\"tag/Notifications/operation/getNotifications\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getNotifications\" id=\"operation/getNotifications\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Notifications/operation/getNotifications\" aria-label=\"tag/Notifications/operation/getNotifications\"></a>Get notification URLs<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Return the notification URL list from the configuration&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Return the notification URL list from the configuration</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;List of notification URLs&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>List of notification URLs</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/notifications</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/notifications</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/notifications</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/notifications</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2a9jq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2a9jq_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2a9jq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2a9jq_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2a9jq_0\" aria-labelledby=\"tab_R_2a9jq_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/notifications\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2a9jq_1\" aria-labelledby=\"tab_R_2a9jq_1\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2e9jq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2e9jq_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2e9jq_0\" aria-labelledby=\"tab_R_2e9jq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span></div></li></ul><span class=\"token punctuation\">]</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Notifications/operation/addNotifications\" data-section-id=\"tag/Notifications/operation/addNotifications\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/addNotifications\" id=\"operation/addNotifications\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Notifications/operation/addNotifications\" aria-label=\"tag/Notifications/operation/addNotifications\"></a>Add notification URLs<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Add one or more notification URLs to the configuration&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Add one or more notification URLs to the configuration</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><h5 class=\"sc-eqYatC czjApA\">Request Body schema: <span class=\"sc-dNFkOE cFlAeY\">application/json</span><div class=\"sc-bEjUoa sc-iIvHqT sc-eTCgfj lhyyLL crXmiY foplsk\">required</div></h5><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"notification_urls\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_urls</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uri<!-- -->&gt;<!-- --> </span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> &lt;<!-- -->uri<!-- --> &gt;</span> ]</span></div> <div><div html=\"&lt;p&gt;List of notification URLs&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>List of notification URLs</p>\n</div></div></div></td></tr></tbody></table><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">201<!-- --> </strong><div html=\"&lt;p&gt;Notification URLs added successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Notification URLs added successfully</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">400<!-- --> </strong><div html=\"&lt;p&gt;Invalid input&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Invalid input</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"post\" class=\"sc-fQLpxn kwcmyC http-verb post\">post</span><span class=\"sc-jvKoal kZcHWP\">/notifications</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/notifications</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/notifications</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/notifications</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2aajq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2aajq_0\" tabindex=\"0\" data-rttab=\"true\">Payload</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2aajq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2aajq_1\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2aajq_2\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2aajq_2\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2aajq_0\" aria-labelledby=\"tab_R_2aajq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;mailto:admin@example.com&quot;</span>,</div></li><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;discord://webhook_id/webhook_token&quot;</span></div></li></ul><span class=\"token punctuation\">]</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2aajq_1\" aria-labelledby=\"tab_R_2aajq_1\"></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2aajq_2\" aria-labelledby=\"tab_R_2aajq_2\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2eajq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2eajq_0\" tabindex=\"0\" data-rttab=\"true\">201</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2eajq_0\" aria-labelledby=\"tab_R_2eajq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span></div></li></ul><span class=\"token punctuation\">]</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Notifications/operation/replaceNotifications\" data-section-id=\"tag/Notifications/operation/replaceNotifications\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/replaceNotifications\" id=\"operation/replaceNotifications\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Notifications/operation/replaceNotifications\" aria-label=\"tag/Notifications/operation/replaceNotifications\"></a>Replace notification URLs<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Replace all notification URLs with the provided list (can be empty)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Replace all notification URLs with the provided list (can be empty)</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><h5 class=\"sc-eqYatC czjApA\">Request Body schema: <span class=\"sc-dNFkOE cFlAeY\">application/json</span><div class=\"sc-bEjUoa sc-iIvHqT sc-eTCgfj lhyyLL crXmiY foplsk\">required</div></h5><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"notification_urls\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_urls</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uri<!-- -->&gt;<!-- --> </span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> &lt;<!-- -->uri<!-- --> &gt;</span> ]</span></div> <div><div html=\"&lt;p&gt;List of notification URLs&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>List of notification URLs</p>\n</div></div></div></td></tr></tbody></table><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Notification URLs replaced successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Notification URLs replaced successfully</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">400<!-- --> </strong><div html=\"&lt;p&gt;Invalid input&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Invalid input</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"put\" class=\"sc-fQLpxn dBzsUh http-verb put\">put</span><span class=\"sc-jvKoal kZcHWP\">/notifications</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/notifications</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/notifications</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/notifications</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2abjq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2abjq_0\" tabindex=\"0\" data-rttab=\"true\">Payload</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2abjq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2abjq_1\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2abjq_2\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2abjq_2\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2abjq_0\" aria-labelledby=\"tab_R_2abjq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span></div></li></ul><span class=\"token punctuation\">]</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2abjq_1\" aria-labelledby=\"tab_R_2abjq_1\"></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2abjq_2\" aria-labelledby=\"tab_R_2abjq_2\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2ebjq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2ebjq_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2ebjq_0\" aria-labelledby=\"tab_R_2ebjq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span></div></li></ul><span class=\"token punctuation\">]</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Notifications/operation/deleteNotifications\" data-section-id=\"tag/Notifications/operation/deleteNotifications\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/deleteNotifications\" id=\"operation/deleteNotifications\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Notifications/operation/deleteNotifications\" aria-label=\"tag/Notifications/operation/deleteNotifications\"></a>Delete notification URLs<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Delete one or more notification URLs from the configuration&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Delete one or more notification URLs from the configuration</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><h5 class=\"sc-eqYatC czjApA\">Request Body schema: <span class=\"sc-dNFkOE cFlAeY\">application/json</span><div class=\"sc-bEjUoa sc-iIvHqT sc-eTCgfj lhyyLL crXmiY foplsk\">required</div></h5><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"last \"><td kind=\"field\" title=\"notification_urls\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">notification_urls</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\">Array of </span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">strings</span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> <!-- -->&lt;<!-- -->uri<!-- -->&gt;<!-- --> </span><span class=\"sc-bEjUoa sc-boKDdR sc-bBhMX lhyyLL jYezsP eA-DYPM\">[ items<span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\"> &lt;<!-- -->uri<!-- --> &gt;</span> ]</span></div> <div><div html=\"&lt;p&gt;List of notification URLs&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>List of notification URLs</p>\n</div></div></div></td></tr></tbody></table><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd oZuve\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">204<!-- --> </strong><div html=\"&lt;p&gt;Notification URLs deleted successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Notification URLs deleted successfully</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">400<!-- --> </strong><div html=\"&lt;p&gt;No matching notification URLs found&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>No matching notification URLs found</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"delete\" class=\"sc-fQLpxn gKcHYQ http-verb delete\">delete</span><span class=\"sc-jvKoal kZcHWP\">/notifications</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/notifications</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/notifications</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/notifications</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_2acjq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_2acjq_0\" tabindex=\"0\" data-rttab=\"true\">Payload</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2acjq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2acjq_1\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_2acjq_2\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_2acjq_2\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_2acjq_0\" aria-labelledby=\"tab_R_2acjq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"notification_urls\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span></div></li></ul><span class=\"token punctuation\">]</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2acjq_1\" aria-labelledby=\"tab_R_2acjq_1\"></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_2acjq_2\" aria-labelledby=\"tab_R_2acjq_2\"></div></div></div></div></div></div><div id=\"tag/Search\" data-section-id=\"tag/Search\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Search\" aria-label=\"tag/Search\"></a>Search</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Search and filter your watches by URL patterns, titles, or tags. Useful for quickly finding specific \nmonitors in large collections or identifying watches that match certain criteria.&lt;/p&gt;\n\"><p>Search and filter your watches by URL patterns, titles, or tags. Useful for quickly finding specific \nmonitors in large collections or identifying watches that match certain criteria.</p>\n</div></div></div><div id=\"tag/Search/operation/searchWatches\" data-section-id=\"tag/Search/operation/searchWatches\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/searchWatches\" id=\"operation/searchWatches\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Search/operation/searchWatches\" aria-label=\"tag/Search/operation/searchWatches\"></a>Search watches<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Search web page change monitors (watches) by URL or title text&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Search web page change monitors (watches) by URL or title text</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">query<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"q\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">q</span><div class=\"sc-bEjUoa sc-iIvHqT lhyyLL crXmiY\">required</div></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div> <div><div html=\"&lt;p&gt;Search query to match against watch URLs and titles&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Search query to match against watch URLs and titles</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tag\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tag</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div> <div><div html=\"&lt;p&gt;Tag name to limit results (name not UUID)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag name to limit results (name not UUID)</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"partial\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">partial</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div> <div><div html=\"&lt;p&gt;Allow partial matching of URL query&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Allow partial matching of URL query</p>\n</div></div></div></td></tr></tbody></table></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Search results&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Search results</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/search</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/search</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/search</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/search</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_ijka_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_ijka_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_ijka_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_ijka_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_ijka_0\" aria-labelledby=\"tab_R_ijka_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/search?q=example.com\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_ijka_1\" aria-labelledby=\"tab_R_ijka_1\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_jjka_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_jjka_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_jjka_0\" aria-labelledby=\"tab_R_jjka_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"watches\"</span>: <button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"uuid\"</span>: <span class=\"token string\">&quot;095be615-a8ad-4c33-8e9c-c7612fbf6c9f&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"url\"</span>: <span class=\"token string\">&quot;</span><a href=\"http://example.com\">http://example.com</a><span class=\"token string\">&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"title\"</span>: <span class=\"token string\">&quot;Example Website Monitor&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"tags\"</span>: <button class=\"collapser\" aria-label=\"expand\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable collapsed\"><span class=\"token string\">&quot;550e8400-e29b-41d4-a716-446655440000&quot;</span></div></li></ul><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"paused\"</span>: <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable collapsed\"><span class=\"property token string\">\"notification_muted\"</span>: <span class=\"token boolean\">false</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Import\" data-section-id=\"tag/Import\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Import\" aria-label=\"tag/Import\"></a>Import</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Bulk import multiple URLs for monitoring. Accepts plain text lists of URLs and can automatically \napply tags, proxy settings, and other configurations to all imported watches simultaneously.&lt;/p&gt;\n\"><p>Bulk import multiple URLs for monitoring. Accepts plain text lists of URLs and can automatically \napply tags, proxy settings, and other configurations to all imported watches simultaneously.</p>\n</div></div></div><div id=\"tag/Import/operation/importWatches\" data-section-id=\"tag/Import/operation/importWatches\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/importWatches\" id=\"operation/importWatches\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Import/operation/importWatches\" aria-label=\"tag/Import/operation/importWatches\"></a>Import watch URLs with configuration<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Import a list of URLs to monitor with optional watch configuration. Accepts line-separated URLs in request body.&lt;/p&gt;\n&lt;p&gt;&lt;strong&gt;Configuration via Query Parameters:&lt;/strong&gt;&lt;/p&gt;\n&lt;p&gt;You can pass ANY watch configuration field as query parameters to apply settings to all imported watches.\nAll parameters from the Watch schema are supported (processor, fetch_backend, notification_urls, etc.).&lt;/p&gt;\n&lt;p&gt;&lt;strong&gt;Special Parameters:&lt;/strong&gt;&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;tag&lt;/code&gt; / &lt;code&gt;tag_uuids&lt;/code&gt; - Assign tags to imported watches&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;proxy&lt;/code&gt; - Use specific proxy for imported watches&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;dedupe&lt;/code&gt; - Skip duplicate URLs (default: true)&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;&lt;strong&gt;Type Conversion:&lt;/strong&gt;&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Booleans: &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;yes&lt;/code&gt;, &lt;code&gt;no&lt;/code&gt;&lt;/li&gt;\n&lt;li&gt;Arrays: Comma-separated or JSON format (&lt;code&gt;[item1,item2]&lt;/code&gt;)&lt;/li&gt;\n&lt;li&gt;Objects: JSON format (&lt;code&gt;{&amp;quot;key&amp;quot;:&amp;quot;value&amp;quot;}&lt;/code&gt;)&lt;/li&gt;\n&lt;li&gt;Numbers: Parsed as int or float&lt;/li&gt;\n&lt;/ul&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Import a list of URLs to monitor with optional watch configuration. Accepts line-separated URLs in request body.</p>\n<p><strong>Configuration via Query Parameters:</strong></p>\n<p>You can pass ANY watch configuration field as query parameters to apply settings to all imported watches.\nAll parameters from the Watch schema are supported (processor, fetch_backend, notification_urls, etc.).</p>\n<p><strong>Special Parameters:</strong></p>\n<ul>\n<li><code>tag</code> / <code>tag_uuids</code> - Assign tags to imported watches</li>\n<li><code>proxy</code> - Use specific proxy for imported watches</li>\n<li><code>dedupe</code> - Skip duplicate URLs (default: true)</li>\n</ul>\n<p><strong>Type Conversion:</strong></p>\n<ul>\n<li>Booleans: <code>true</code>, <code>false</code>, <code>1</code>, <code>0</code>, <code>yes</code>, <code>no</code></li>\n<li>Arrays: Comma-separated or JSON format (<code>[item1,item2]</code>)</li>\n<li>Objects: JSON format (<code>{&quot;key&quot;:&quot;value&quot;}</code>)</li>\n<li>Numbers: Parsed as int or float</li>\n</ul>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class=\"sc-eqYatC czjApA\">query<!-- --> Parameters</h5><table class=\"sc-eqNDNG icJLQx\"><tbody><tr class=\"\"><td kind=\"field\" title=\"tag_uuids\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tag_uuids</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div> <div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Example:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">tag_uuids=550e8400-e29b-41d4-a716-446655440000</span></div><div><div html=\"&lt;p&gt;Tag UUID(s) to apply to imported watches (comma-separated for multiple)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag UUID(s) to apply to imported watches (comma-separated for multiple)</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"tag\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">tag</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div> <div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Example:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">tag=production</span></div><div><div html=\"&lt;p&gt;Tag name to apply to imported watches&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Tag name to apply to imported watches</p>\n</div></div></div></td></tr><tr class=\"\"><td kind=\"field\" title=\"proxy\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">proxy</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div> <div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Example:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">proxy=proxy1</span></div><div><div html=\"&lt;p&gt;Proxy key to use for imported watches&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Proxy key to use for imported watches</p>\n</div></div></div></td></tr><tr class=\"last \"><td kind=\"field\" title=\"dedupe\" class=\"sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ\"><span class=\"sc-hwddKA cteAyA\"></span><span class=\"property-name\">dedupe</span></td><td class=\"sc-gGKoUb ixGaBD\"><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">boolean</span></div><div><span class=\"sc-bEjUoa lhyyLL\"> <!-- -->Default:<!-- --> </span> <span class=\"sc-bEjUoa sc-dTWiOz lhyyLL kMQdIk\">true</span></div> <div><div html=\"&lt;p&gt;Skip duplicate URLs (default true)&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Skip duplicate URLs (default true)</p>\n</div></div></div></td></tr></tbody></table></div><h5 class=\"sc-eqYatC czjApA\">Request Body schema: <span class=\"sc-dNFkOE cFlAeY\">text/plain</span><div class=\"sc-bEjUoa sc-iIvHqT sc-eTCgfj lhyyLL crXmiY foplsk\">required</div></h5><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"></div><div><div><div><span class=\"sc-bEjUoa sc-boKDdR lhyyLL jYezsP\"></span><span class=\"sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq\">string</span></div> <div><div html=\"\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"></div></div></div></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;URLs imported successfully&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>URLs imported successfully</p>\n</div></button></div><div><button class=\"sc-jIDBmd kQCDrg\" disabled=\"\"><strong class=\"sc-eJvlPh fBhAXU\">500<!-- --> </strong><div html=\"&lt;p&gt;Server error&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Server error</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"post\" class=\"sc-fQLpxn kwcmyC http-verb post\">post</span><span class=\"sc-jvKoal kZcHWP\">/import</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/import</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/import</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/import</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_ijkq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_ijkq_0\" tabindex=\"0\" data-rttab=\"true\">Payload</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_ijkq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_ijkq_1\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_ijkq_2\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_ijkq_2\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_ijkq_0\" aria-labelledby=\"tab_R_ijkq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">text/plain</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">https<span class=\"token punctuation\">:</span><span class=\"token operator\">/</span><span class=\"token operator\">/</span>example<span class=\"token punctuation\">.</span>com\nhttps<span class=\"token punctuation\">:</span><span class=\"token operator\">/</span><span class=\"token operator\">/</span>example<span class=\"token punctuation\">.</span>org\nhttps<span class=\"token punctuation\">:</span><span class=\"token operator\">/</span><span class=\"token operator\">/</span>example<span class=\"token punctuation\">.</span>net\n</pre></div></div></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_ijkq_1\" aria-labelledby=\"tab_R_ijkq_1\"></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_ijkq_2\" aria-labelledby=\"tab_R_ijkq_2\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_jjkq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_jjkq_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_jjkq_0\" aria-labelledby=\"tab_R_jjkq_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">[</span><span class=\"ellipsis\"></span><ul class=\"array collapsible\"><li><div class=\"hoverable \"><span class=\"token string\">&quot;497f6eca-6276-4993-bfeb-53cbbbba6f08&quot;</span></div></li></ul><span class=\"token punctuation\">]</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/System-Information\" data-section-id=\"tag/System-Information\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/System-Information\" aria-label=\"tag/System-Information\"></a>System Information</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;Retrieve system status and statistics about your changedetection.io instance, including total watch\ncounts, uptime information, and version details.&lt;/p&gt;\n\"><p>Retrieve system status and statistics about your changedetection.io instance, including total watch\ncounts, uptime information, and version details.</p>\n</div></div></div><div id=\"tag/System-Information/operation/getSystemInfo\" data-section-id=\"tag/System-Information/operation/getSystemInfo\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getSystemInfo\" id=\"operation/getSystemInfo\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/System-Information/operation/getSystemInfo\" aria-label=\"tag/System-Information/operation/getSystemInfo\"></a>Get system information<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Return information about the current system state&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Return information about the current system state</p>\n</div></div><div class=\"sc-ikkVnJ deUlC\"><div class=\"sc-hWgKua dPSGXF\"><h5 class=\"sc-eqYatC sc-gFqXPY czjApA jCoZLr\">Authorizations:</h5><svg class=\"sc-dntSTA FtowP\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></div><div class=\"sc-jBaHRL fUkQtw\"><span class=\"sc-iVnIWt gRXavu\"><span class=\"sc-hqtLyI hRtRoN\"><i>ApiKeyAuth</i></span></span></div></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;System information&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>System information</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/systeminfo</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/systeminfo</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/systeminfo</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/systeminfo</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_ijla_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_ijla_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_ijla_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_ijla_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_ijla_0\" aria-labelledby=\"tab_R_ijla_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\">curl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/systeminfo\"</span> \\\n  <span class=\"token operator\">-</span>H <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_ijla_1\" aria-labelledby=\"tab_R_ijla_1\"></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Response samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"tab-success react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_jjla_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_jjla_0\" tabindex=\"0\" data-rttab=\"true\">200</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_jjla_0\" aria-labelledby=\"tab_R_jjla_0\"><div><div class=\"sc-bSFBcf iLdyBp\"><span class=\"sc-gahYZc cXitJ\">Content type</span><div class=\"sc-bAehkN iNRAJK\">application/json</div></div><div class=\"sc-blIAwI eKKwxo\"><div class=\"sc-dClGHI fdRrNy\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><div tabindex=\"0\" class=\"sc-eVqvcJ kIppRw sc-fhfEft dFvLDb\"><div class=\"redoc-json\"><code><button class=\"collapser\" aria-label=\"collapse\"></button><span class=\"token punctuation\">{</span><span class=\"ellipsis\"></span><ul class=\"obj collapsible\"><li><div class=\"hoverable \"><span class=\"property token string\">\"watch_count\"</span>: <span class=\"token number\">42</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"tag_count\"</span>: <span class=\"token number\">5</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"uptime\"</span>: <span class=\"token string\">&quot;2 days, 3:45:12&quot;</span><span class=\"token punctuation\">,</span></div></li><li><div class=\"hoverable \"><span class=\"property token string\">\"version\"</span>: <span class=\"token string\">&quot;0.50.10&quot;</span></div></li></ul><span class=\"token punctuation\">}</span></code></div></div></div></div></div></div></div></div></div></div></div><div id=\"tag/Plugin-API-Extensions\" data-section-id=\"tag/Plugin-API-Extensions\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Plugin-API-Extensions\" aria-label=\"tag/Plugin-API-Extensions\"></a>Plugin API Extensions</h2></div></div></div><div id=\"tag/Plugin-API-Extensions/How-Processor-Plugins-Extend-the-API\" data-section-id=\"tag/Plugin-API-Extensions/How-Processor-Plugins-Extend-the-API\" class=\"sc-dTvVRJ bPmFpz\"><div class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-dYwGCk cXqSZD\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Plugin-API-Extensions/How-Processor-Plugins-Extend-the-API\" aria-label=\"tag/Plugin-API-Extensions/How-Processor-Plugins-Extend-the-API\"></a>How Processor Plugins Extend the API</h2></div></div><div class=\"sc-ggWZvA dCzIPc\"><div class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred redoc-markdown \" html=\"&lt;p&gt;changedetection.io uses a &lt;strong&gt;processor plugin&lt;/strong&gt; system to handle different types of change detection.\nEach processor lives in &lt;code&gt;changedetectionio/processors/&amp;lt;name&amp;gt;/&lt;/code&gt; and may include an &lt;code&gt;api.yaml&lt;/code&gt; file\nthat extends the core Watch schema with processor-specific configuration fields.&lt;/p&gt;\n&lt;h3 id=&quot;how-it-works&quot;&gt;How it works&lt;/h3&gt;\n&lt;p&gt;At startup, changedetection.io scans all installed processors for an &lt;code&gt;api.yaml&lt;/code&gt; file. Any schemas\nand code samples defined there are deep-merged into the live API specification, making the\nprocessor&amp;#39;s configuration fields valid on all watch create and update requests.&lt;/p&gt;\n&lt;p&gt;The live, fully-merged spec is always available at &lt;code&gt;/api/v1/full-spec&lt;/code&gt; — use that URL with\nSwagger UI or Redoc to see the complete schema for your specific installation.&lt;/p&gt;\n&lt;hr&gt;\n&lt;h3 id=&quot;writing-a-processor-apiyaml&quot;&gt;Writing a processor &lt;code&gt;api.yaml&lt;/code&gt;&lt;/h3&gt;\n&lt;p&gt;Place an &lt;code&gt;api.yaml&lt;/code&gt; in the processor plugin&amp;#39;s own directory, alongside its &lt;code&gt;__init__.py&lt;/code&gt;\n(e.g. &lt;code&gt;changedetectionio/processors/my_processor/api.yaml&lt;/code&gt;). The schema name &lt;strong&gt;must&lt;/strong&gt; follow the\nconvention &lt;code&gt;processor_config_&amp;lt;processor_name&amp;gt;&lt;/code&gt; (e.g. &lt;code&gt;processor_config_restock_diff&lt;/code&gt;). That same\nkey is used as the JSON field name when creating or updating a watch.&lt;/p&gt;\n&lt;p&gt;A minimal &lt;code&gt;api.yaml&lt;/code&gt; for a hypothetical &lt;code&gt;my_processor&lt;/code&gt;:&lt;/p&gt;\n&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;components&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n  &lt;span class=&quot;token key atrule&quot;&gt;schemas&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n    &lt;span class=&quot;token key atrule&quot;&gt;processor_config_my_processor&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n      &lt;span class=&quot;token key atrule&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; object\n      &lt;span class=&quot;token key atrule&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Configuration for my_processor\n      &lt;span class=&quot;token key atrule&quot;&gt;properties&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n        &lt;span class=&quot;token key atrule&quot;&gt;some_option&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n          &lt;span class=&quot;token key atrule&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; boolean\n          &lt;span class=&quot;token key atrule&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt;\n          &lt;span class=&quot;token key atrule&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Enable some behaviour\n\n&lt;span class=&quot;token key atrule&quot;&gt;paths&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n  &lt;span class=&quot;token key atrule&quot;&gt;/watch&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n    &lt;span class=&quot;token key atrule&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n      &lt;span class=&quot;token key atrule&quot;&gt;x-code-samples&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;\n        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;lang&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; curl\n          &lt;span class=&quot;token key atrule&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; my_processor example\n          &lt;span class=&quot;token key atrule&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;token scalar string&quot;&gt;\n            curl -X POST &quot;http://localhost:5000/api/v1/watch&quot; \\\n              -H &quot;x-api-key: YOUR_API_KEY&quot; \\\n              -H &quot;Content-Type: application/json&quot; \\\n              -d &#x27;{\n                &quot;url&quot;: &quot;https://example.com&quot;,\n                &quot;processor&quot;: &quot;my_processor&quot;,\n                &quot;processor_config_my_processor&quot;: { &quot;some_option&quot;: true }\n              }&#x27;&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;\n&lt;p&gt;The &lt;code&gt;paths&lt;/code&gt; section in &lt;code&gt;api.yaml&lt;/code&gt; is used only for injecting additional &lt;code&gt;x-code-samples&lt;/code&gt; into\nexisting endpoints — you cannot define new routes via plugin.&lt;/p&gt;\n&lt;hr&gt;\n&lt;h3 id=&quot;built-in-plugin-restock_diff&quot;&gt;Built-in plugin: &lt;code&gt;restock_diff&lt;/code&gt;&lt;/h3&gt;\n&lt;p&gt;The &lt;code&gt;restock_diff&lt;/code&gt; processor is always shipped with changedetection.io. It monitors product\navailability and price changes using structured data (JSON-LD / schema.org microdata) and\ntext heuristics. It is activated by setting &lt;code&gt;&amp;quot;processor&amp;quot;: &amp;quot;restock_diff&amp;quot;&lt;/code&gt; on a watch.&lt;/p&gt;\n&lt;p&gt;It adds the &lt;code&gt;processor_config_restock_diff&lt;/code&gt; block to the Watch schema with these fields:&lt;/p&gt;\n&lt;table&gt;\n&lt;thead&gt;\n&lt;tr&gt;\n&lt;th&gt;Field&lt;/th&gt;\n&lt;th&gt;Type&lt;/th&gt;\n&lt;th&gt;Default&lt;/th&gt;\n&lt;th&gt;Description&lt;/th&gt;\n&lt;/tr&gt;\n&lt;/thead&gt;\n&lt;tbody&gt;&lt;tr&gt;\n&lt;td&gt;&lt;code&gt;in_stock_processing&lt;/code&gt;&lt;/td&gt;\n&lt;td&gt;string&lt;/td&gt;\n&lt;td&gt;&lt;code&gt;in_stock_only&lt;/code&gt;&lt;/td&gt;\n&lt;td&gt;&lt;code&gt;in_stock_only&lt;/code&gt; — only alert Out-of-Stock→In-Stock · &lt;code&gt;all_changes&lt;/code&gt; — alert any availability change · &lt;code&gt;off&lt;/code&gt; — disable stock tracking&lt;/td&gt;\n&lt;/tr&gt;\n&lt;tr&gt;\n&lt;td&gt;&lt;code&gt;follow_price_changes&lt;/code&gt;&lt;/td&gt;\n&lt;td&gt;boolean&lt;/td&gt;\n&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;\n&lt;td&gt;Monitor and alert on price changes&lt;/td&gt;\n&lt;/tr&gt;\n&lt;tr&gt;\n&lt;td&gt;&lt;code&gt;price_change_min&lt;/code&gt;&lt;/td&gt;\n&lt;td&gt;number|null&lt;/td&gt;\n&lt;td&gt;—&lt;/td&gt;\n&lt;td&gt;Alert when price drops &lt;strong&gt;below&lt;/strong&gt; this value&lt;/td&gt;\n&lt;/tr&gt;\n&lt;tr&gt;\n&lt;td&gt;&lt;code&gt;price_change_max&lt;/code&gt;&lt;/td&gt;\n&lt;td&gt;number|null&lt;/td&gt;\n&lt;td&gt;—&lt;/td&gt;\n&lt;td&gt;Alert when price rises &lt;strong&gt;above&lt;/strong&gt; this value&lt;/td&gt;\n&lt;/tr&gt;\n&lt;tr&gt;\n&lt;td&gt;&lt;code&gt;price_change_threshold_percent&lt;/code&gt;&lt;/td&gt;\n&lt;td&gt;number|null&lt;/td&gt;\n&lt;td&gt;—&lt;/td&gt;\n&lt;td&gt;Minimum % change since the original price to trigger an alert&lt;/td&gt;\n&lt;/tr&gt;\n&lt;/tbody&gt;&lt;/table&gt;\n&lt;h4 id=&quot;create--add-a-restockprice-monitor&quot;&gt;CREATE — Add a restock/price monitor&lt;/h4&gt;\n&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;curl&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-X&lt;/span&gt; POST &lt;span class=&quot;token string&quot;&gt;&quot;http://localhost:5000/api/v1/watch&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\\&lt;/span&gt;\n  &lt;span class=&quot;token parameter variable&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;x-api-key: YOUR_API_KEY&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\\&lt;/span&gt;\n  &lt;span class=&quot;token parameter variable&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Content-Type: application/json&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\\&lt;/span&gt;\n  &lt;span class=&quot;token parameter variable&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#x27;{\n    &quot;url&quot;: &quot;https://example.com/product/widget&quot;,\n    &quot;processor&quot;: &quot;restock_diff&quot;,\n    &quot;processor_config_restock_diff&quot;: {\n      &quot;in_stock_processing&quot;: &quot;in_stock_only&quot;,\n      &quot;follow_price_changes&quot;: true,\n      &quot;price_change_threshold_percent&quot;: 5\n    }\n  }&#x27;&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;\n&lt;h4 id=&quot;read--retrieve-the-monitor&quot;&gt;READ — Retrieve the monitor&lt;/h4&gt;\n&lt;p&gt;The response JSON includes &lt;code&gt;processor_config_restock_diff&lt;/code&gt; alongside all standard watch fields:&lt;/p&gt;\n&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;curl&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-X&lt;/span&gt; GET &lt;span class=&quot;token string&quot;&gt;&quot;http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\\&lt;/span&gt;\n  &lt;span class=&quot;token parameter variable&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;x-api-key: YOUR_API_KEY&quot;&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;\n&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;\n  &lt;span class=&quot;token string-property property&quot;&gt;&quot;uuid&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;cc0cfffa-f449-477b-83ea-0caafd1dc091&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;\n  &lt;span class=&quot;token string-property property&quot;&gt;&quot;url&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;https://example.com/product/widget&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;\n  &lt;span class=&quot;token string-property property&quot;&gt;&quot;processor&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;restock_diff&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;\n  &lt;span class=&quot;token string-property property&quot;&gt;&quot;processor_config_restock_diff&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;\n    &lt;span class=&quot;token string-property property&quot;&gt;&quot;in_stock_processing&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;in_stock_only&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;\n    &lt;span class=&quot;token string-property property&quot;&gt;&quot;follow_price_changes&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;\n    &lt;span class=&quot;token string-property property&quot;&gt;&quot;price_change_threshold_percent&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;\n    &lt;span class=&quot;token string-property property&quot;&gt;&quot;price_change_min&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;\n    &lt;span class=&quot;token string-property property&quot;&gt;&quot;price_change_max&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;\n  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;\n&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;\n&lt;h4 id=&quot;update--change-thresholds-without-recreating-the-monitor&quot;&gt;UPDATE — Change thresholds without recreating the monitor&lt;/h4&gt;\n&lt;p&gt;Only fields included in the request body are updated; omitted fields are left unchanged.&lt;/p&gt;\n&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;curl&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-X&lt;/span&gt; PUT &lt;span class=&quot;token string&quot;&gt;&quot;http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\\&lt;/span&gt;\n  &lt;span class=&quot;token parameter variable&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;x-api-key: YOUR_API_KEY&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\\&lt;/span&gt;\n  &lt;span class=&quot;token parameter variable&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Content-Type: application/json&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\\&lt;/span&gt;\n  &lt;span class=&quot;token parameter variable&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#x27;{\n    &quot;processor_config_restock_diff&quot;: {\n      &quot;in_stock_processing&quot;: &quot;all_changes&quot;,\n      &quot;follow_price_changes&quot;: true,\n      &quot;price_change_min&quot;: 10.00,\n      &quot;price_change_max&quot;: 500.00\n    }\n  }&#x27;&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;\n&lt;h4 id=&quot;delete--remove-the-monitor&quot;&gt;DELETE — Remove the monitor&lt;/h4&gt;\n&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;curl&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-X&lt;/span&gt; DELETE &lt;span class=&quot;token string&quot;&gt;&quot;http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;\\&lt;/span&gt;\n  &lt;span class=&quot;token parameter variable&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;x-api-key: YOUR_API_KEY&quot;&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;\n&lt;hr&gt;\n&lt;p&gt;For the complete schema-validated documentation including all processor fields, fetch the live spec\nand load it into Swagger UI or Redoc:&lt;/p&gt;\n&lt;pre&gt;&lt;code&gt;GET &lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;api&lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;v1&lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;full&lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt;spec\n&lt;/code&gt;&lt;/pre&gt;\n\"><p>changedetection.io uses a <strong>processor plugin</strong> system to handle different types of change detection.\nEach processor lives in <code>changedetectionio/processors/&lt;name&gt;/</code> and may include an <code>api.yaml</code> file\nthat extends the core Watch schema with processor-specific configuration fields.</p>\n<h3 id=\"how-it-works\">How it works</h3>\n<p>At startup, changedetection.io scans all installed processors for an <code>api.yaml</code> file. Any schemas\nand code samples defined there are deep-merged into the live API specification, making the\nprocessor&#39;s configuration fields valid on all watch create and update requests.</p>\n<p>The live, fully-merged spec is always available at <code>/api/v1/full-spec</code> — use that URL with\nSwagger UI or Redoc to see the complete schema for your specific installation.</p>\n<hr>\n<h3 id=\"writing-a-processor-apiyaml\">Writing a processor <code>api.yaml</code></h3>\n<p>Place an <code>api.yaml</code> in the processor plugin&#39;s own directory, alongside its <code>__init__.py</code>\n(e.g. <code>changedetectionio/processors/my_processor/api.yaml</code>). The schema name <strong>must</strong> follow the\nconvention <code>processor_config_&lt;processor_name&gt;</code> (e.g. <code>processor_config_restock_diff</code>). That same\nkey is used as the JSON field name when creating or updating a watch.</p>\n<p>A minimal <code>api.yaml</code> for a hypothetical <code>my_processor</code>:</p>\n<pre><code class=\"language-yaml\"><span class=\"token key atrule\">components</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">schemas</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">processor_config_my_processor</span><span class=\"token punctuation\">:</span>\n      <span class=\"token key atrule\">type</span><span class=\"token punctuation\">:</span> object\n      <span class=\"token key atrule\">description</span><span class=\"token punctuation\">:</span> Configuration for my_processor\n      <span class=\"token key atrule\">properties</span><span class=\"token punctuation\">:</span>\n        <span class=\"token key atrule\">some_option</span><span class=\"token punctuation\">:</span>\n          <span class=\"token key atrule\">type</span><span class=\"token punctuation\">:</span> boolean\n          <span class=\"token key atrule\">default</span><span class=\"token punctuation\">:</span> <span class=\"token boolean important\">true</span>\n          <span class=\"token key atrule\">description</span><span class=\"token punctuation\">:</span> Enable some behaviour\n\n<span class=\"token key atrule\">paths</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">/watch</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">post</span><span class=\"token punctuation\">:</span>\n      <span class=\"token key atrule\">x-code-samples</span><span class=\"token punctuation\">:</span>\n        <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">lang</span><span class=\"token punctuation\">:</span> curl\n          <span class=\"token key atrule\">label</span><span class=\"token punctuation\">:</span> my_processor example\n          <span class=\"token key atrule\">source</span><span class=\"token punctuation\">:</span> <span class=\"token punctuation\">|</span><span class=\"token scalar string\">\n            curl -X POST \"http://localhost:5000/api/v1/watch\" \\\n              -H \"x-api-key: YOUR_API_KEY\" \\\n              -H \"Content-Type: application/json\" \\\n              -d '{\n                \"url\": \"https://example.com\",\n                \"processor\": \"my_processor\",\n                \"processor_config_my_processor\": { \"some_option\": true }\n              }'</span>\n</code></pre>\n<p>The <code>paths</code> section in <code>api.yaml</code> is used only for injecting additional <code>x-code-samples</code> into\nexisting endpoints — you cannot define new routes via plugin.</p>\n<hr>\n<h3 id=\"built-in-plugin-restock_diff\">Built-in plugin: <code>restock_diff</code></h3>\n<p>The <code>restock_diff</code> processor is always shipped with changedetection.io. It monitors product\navailability and price changes using structured data (JSON-LD / schema.org microdata) and\ntext heuristics. It is activated by setting <code>&quot;processor&quot;: &quot;restock_diff&quot;</code> on a watch.</p>\n<p>It adds the <code>processor_config_restock_diff</code> block to the Watch schema with these fields:</p>\n<table>\n<thead>\n<tr>\n<th>Field</th>\n<th>Type</th>\n<th>Default</th>\n<th>Description</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>in_stock_processing</code></td>\n<td>string</td>\n<td><code>in_stock_only</code></td>\n<td><code>in_stock_only</code> — only alert Out-of-Stock→In-Stock · <code>all_changes</code> — alert any availability change · <code>off</code> — disable stock tracking</td>\n</tr>\n<tr>\n<td><code>follow_price_changes</code></td>\n<td>boolean</td>\n<td><code>true</code></td>\n<td>Monitor and alert on price changes</td>\n</tr>\n<tr>\n<td><code>price_change_min</code></td>\n<td>number|null</td>\n<td>—</td>\n<td>Alert when price drops <strong>below</strong> this value</td>\n</tr>\n<tr>\n<td><code>price_change_max</code></td>\n<td>number|null</td>\n<td>—</td>\n<td>Alert when price rises <strong>above</strong> this value</td>\n</tr>\n<tr>\n<td><code>price_change_threshold_percent</code></td>\n<td>number|null</td>\n<td>—</td>\n<td>Minimum % change since the original price to trigger an alert</td>\n</tr>\n</tbody></table>\n<h4 id=\"create--add-a-restockprice-monitor\">CREATE — Add a restock/price monitor</h4>\n<pre><code class=\"language-bash\"><span class=\"token function\">curl</span> <span class=\"token parameter variable\">-X</span> POST <span class=\"token string\">\"http://localhost:5000/api/v1/watch\"</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token parameter variable\">-H</span> <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token parameter variable\">-H</span> <span class=\"token string\">\"Content-Type: application/json\"</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token parameter variable\">-d</span> <span class=\"token string\">'{\n    \"url\": \"https://example.com/product/widget\",\n    \"processor\": \"restock_diff\",\n    \"processor_config_restock_diff\": {\n      \"in_stock_processing\": \"in_stock_only\",\n      \"follow_price_changes\": true,\n      \"price_change_threshold_percent\": 5\n    }\n  }'</span>\n</code></pre>\n<h4 id=\"read--retrieve-the-monitor\">READ — Retrieve the monitor</h4>\n<p>The response JSON includes <code>processor_config_restock_diff</code> alongside all standard watch fields:</p>\n<pre><code class=\"language-bash\"><span class=\"token function\">curl</span> <span class=\"token parameter variable\">-X</span> GET <span class=\"token string\">\"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\"</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token parameter variable\">-H</span> <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</code></pre>\n<pre><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token string-property property\">\"uuid\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"cc0cfffa-f449-477b-83ea-0caafd1dc091\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"url\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"https://example.com/product/widget\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"processor\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"restock_diff\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"processor_config_restock_diff\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token string-property property\">\"in_stock_processing\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"in_stock_only\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string-property property\">\"follow_price_changes\"</span><span class=\"token operator\">:</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string-property property\">\"price_change_threshold_percent\"</span><span class=\"token operator\">:</span> <span class=\"token number\">5</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string-property property\">\"price_change_min\"</span><span class=\"token operator\">:</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string-property property\">\"price_change_max\"</span><span class=\"token operator\">:</span> <span class=\"token keyword\">null</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span>\n</code></pre>\n<h4 id=\"update--change-thresholds-without-recreating-the-monitor\">UPDATE — Change thresholds without recreating the monitor</h4>\n<p>Only fields included in the request body are updated; omitted fields are left unchanged.</p>\n<pre><code class=\"language-bash\"><span class=\"token function\">curl</span> <span class=\"token parameter variable\">-X</span> PUT <span class=\"token string\">\"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\"</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token parameter variable\">-H</span> <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token parameter variable\">-H</span> <span class=\"token string\">\"Content-Type: application/json\"</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token parameter variable\">-d</span> <span class=\"token string\">'{\n    \"processor_config_restock_diff\": {\n      \"in_stock_processing\": \"all_changes\",\n      \"follow_price_changes\": true,\n      \"price_change_min\": 10.00,\n      \"price_change_max\": 500.00\n    }\n  }'</span>\n</code></pre>\n<h4 id=\"delete--remove-the-monitor\">DELETE — Remove the monitor</h4>\n<pre><code class=\"language-bash\"><span class=\"token function\">curl</span> <span class=\"token parameter variable\">-X</span> DELETE <span class=\"token string\">\"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\"</span> <span class=\"token punctuation\">\\</span>\n  <span class=\"token parameter variable\">-H</span> <span class=\"token string\">\"x-api-key: YOUR_API_KEY\"</span>\n</code></pre>\n<hr>\n<p>For the complete schema-validated documentation including all processor fields, fetch the live spec\nand load it into Swagger UI or Redoc:</p>\n<pre><code>GET <span class=\"token operator\">/</span>api<span class=\"token operator\">/</span>v1<span class=\"token operator\">/</span>full<span class=\"token operator\">-</span>spec\n</code></pre>\n</div></div></div><div id=\"tag/Plugin-API-Extensions/operation/getFullApiSpec\" data-section-id=\"tag/Plugin-API-Extensions/operation/getFullApiSpec\" class=\"sc-dTvVRJ gHrCVQ\"><div data-section-id=\"operation/getFullApiSpec\" id=\"operation/getFullApiSpec\" class=\"sc-jJLAfE gkiSyE\"><div class=\"sc-ggWZvA fqkwbU\"><h2 class=\"sc-kNOymR iFSqkw\"><a class=\"sc-kcLKEh fRdsOi\" href=\"#tag/Plugin-API-Extensions/operation/getFullApiSpec\" aria-label=\"tag/Plugin-API-Extensions/operation/getFullApiSpec\"></a>Get full live API spec<!-- --> </h2><div class=\"sc-bfjeOH txIPi\"><div html=\"&lt;p&gt;Return the fully merged OpenAPI specification for this instance.&lt;/p&gt;\n&lt;p&gt;Unlike the static &lt;code&gt;api-spec.yaml&lt;/code&gt; shipped with the application, this endpoint returns the\nspec dynamically merged with any &lt;code&gt;api.yaml&lt;/code&gt; schemas provided by installed processor plugins.&lt;/p&gt;\n&lt;p&gt;&lt;strong&gt;Use this URL&lt;/strong&gt; with Swagger UI or Redoc to get schema-accurate documentation for your\nspecific install — it includes every &lt;code&gt;processor_config_&amp;lt;name&amp;gt;&lt;/code&gt; schema block contributed by\ninstalled processors (e.g. &lt;code&gt;processor_config_restock_diff&lt;/code&gt; from the built-in restock plugin).&lt;/p&gt;\n&lt;p&gt;This endpoint requires no authentication and returns YAML.&lt;/p&gt;\n&lt;p&gt;To load it directly in Swagger UI, paste the URL into the &amp;quot;Explore&amp;quot; box:&lt;/p&gt;\n&lt;pre&gt;&lt;code&gt;http&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;localhost&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;5000&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;api&lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;v1&lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt;full&lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt;spec\n&lt;/code&gt;&lt;/pre&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw kbZred\"><p>Return the fully merged OpenAPI specification for this instance.</p>\n<p>Unlike the static <code>api-spec.yaml</code> shipped with the application, this endpoint returns the\nspec dynamically merged with any <code>api.yaml</code> schemas provided by installed processor plugins.</p>\n<p><strong>Use this URL</strong> with Swagger UI or Redoc to get schema-accurate documentation for your\nspecific install — it includes every <code>processor_config_&lt;name&gt;</code> schema block contributed by\ninstalled processors (e.g. <code>processor_config_restock_diff</code> from the built-in restock plugin).</p>\n<p>This endpoint requires no authentication and returns YAML.</p>\n<p>To load it directly in Swagger UI, paste the URL into the &quot;Explore&quot; box:</p>\n<pre><code>http<span class=\"token punctuation\">:</span><span class=\"token operator\">/</span><span class=\"token operator\">/</span>localhost<span class=\"token punctuation\">:</span><span class=\"token number\">5000</span><span class=\"token operator\">/</span>api<span class=\"token operator\">/</span>v1<span class=\"token operator\">/</span>full<span class=\"token operator\">-</span>spec\n</code></pre>\n</div></div><div><h3 class=\"sc-gDzyrw kjrVcG\">Responses</h3><div><button class=\"sc-jIDBmd lkmdtA\"><svg class=\"sc-dntSTA cGxVlA\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg><strong class=\"sc-eJvlPh fBhAXU\">200<!-- --> </strong><div html=\"&lt;p&gt;Merged OpenAPI specification in YAML format. Includes all processor plugin schemas\n(e.g. &lt;code&gt;processor_config_restock_diff&lt;/code&gt;) not present in the static &lt;code&gt;api-spec.yaml&lt;/code&gt;.&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp sc-etsjJW kIppRw jnwENr ljKHqG\"><p>Merged OpenAPI specification in YAML format. Includes all processor plugin schemas\n(e.g. <code>processor_config_restock_diff</code>) not present in the static <code>api-spec.yaml</code>.</p>\n</div></button></div></div></div><div class=\"sc-jwTyAe sc-hjsuWn bDYKKx FFPsr\"><div class=\"sc-eZSpzM jjnszm\"><button class=\"sc-buTqWO iPCVMX\"><span type=\"get\" class=\"sc-fQLpxn dynMBc http-verb get\">get</span><span class=\"sc-jvKoal kZcHWP\">/full-spec</span><svg class=\"sc-dntSTA iuNpUs\" style=\"margin-right:-25px\" version=\"1.1\" viewBox=\"0 0 24 24\" x=\"0\" xmlns=\"http://www.w3.org/2000/svg\" y=\"0\" aria-hidden=\"true\"><polygon points=\"17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 \"></polygon></svg></button><div aria-hidden=\"true\" class=\"sc-ecJghI ga-DQLq\"><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Development server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Development server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>http://localhost:5000/api/v1</span>/full-spec</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Production server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Production server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>https://yourdomain.com/api/v1</span>/full-spec</div></div></div><div class=\"sc-iyBeIh icOxsG\"><div html=\"&lt;p&gt;Custom server&lt;/p&gt;\n\" class=\"sc-eVqvcJ sc-fszimp kIppRw drqpJr\"><p>Custom server</p>\n</div><div tabindex=\"0\" role=\"button\"><div class=\"sc-xKhEK okJpy\"><span>{protocol}://{host}/api/v1</span>/full-spec</div></div></div></div></div><div><h3 class=\"sc-lgpSej drJHMo\"> <!-- -->Request samples<!-- --> </h3><div class=\"sc-cOpnSz fyxuKi\" data-rttabs=\"true\"><ul class=\"react-tabs__tab-list\" role=\"tablist\"><li class=\"react-tabs__tab react-tabs__tab--selected\" role=\"tab\" id=\"tab_R_156lq_0\" aria-selected=\"true\" aria-disabled=\"false\" aria-controls=\"panel_R_156lq_0\" tabindex=\"0\" data-rttab=\"true\">curl</li><li class=\"react-tabs__tab\" role=\"tab\" id=\"tab_R_156lq_1\" aria-selected=\"false\" aria-disabled=\"false\" aria-controls=\"panel_R_156lq_1\" data-rttab=\"true\">Python</li></ul><div class=\"react-tabs__tab-panel react-tabs__tab-panel--selected\" role=\"tabpanel\" id=\"panel_R_156lq_0\" aria-labelledby=\"tab_R_156lq_0\"><div class=\"sc-cdmAjP gsEOpk\"><div class=\"sc-bbbBoY bBWkcI\"><button><div class=\"sc-fYmhhH iNCOCX\">Copy</div></button></div><pre class=\"sc-eVqvcJ sc-jytpVa kIppRw cCzeOT\"># Fetch the live merged spec <span class=\"token punctuation\">(</span>no API key needed<span class=\"token punctuation\">)</span>\ncurl <span class=\"token operator\">-</span>X GET <span class=\"token string\">\"http://localhost:5000/api/v1/full-spec\"</span>\n</pre></div></div><div class=\"react-tabs__tab-panel\" role=\"tabpanel\" id=\"panel_R_156lq_1\" aria-labelledby=\"tab_R_156lq_1\"></div></div></div></div></div></div></div><div class=\"sc-evkzZa iZqpqg\"></div></div></div>\n      <script>\n      const __redoc_state = {\"menu\":{\"activeItemIdx\":-1},\"spec\":{\"data\":{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"ChangeDetection.io API\",\"description\":\"# ChangeDetection.io Web page monitoring and notifications API\\n\\nREST API for managing Page watches, Group tags, and Notifications.\\n\\nchangedetection.io can be driven by its built in simple API, in the examples below you will also find `curl` command line and `python` examples to help you get started faster.\\n\\n## Where to find my API key?\\n\\nThe API key can be easily found under the **SETTINGS** then **API** tab of changedetection.io dashboard.  \\nSimply click the API key to automatically copy it to your clipboard.\\n\\n![Where to find the API key](./where-to-get-api-key.jpeg)\\n\\n## Connection URL\\n\\nThe API can be found at `/api/v1/`, so for example if you run changedetection.io locally on port 5000, then URL would be `http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history`.\\n\\nIf you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example:  \\n`https://<your login url>/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history`\\n\\n## Authentication\\n\\nAlmost all API requests require some authentication, this is provided as an **API Key** in the header of the HTTP request.\\n\\nFor example: `x-api-key: YOUR_API_KEY`\\n\",\"version\":\"0.1.6\",\"contact\":{\"name\":\"ChangeDetection.io\",\"url\":\"https://github.com/dgtlmoon/changedetection.io\"},\"license\":{\"name\":\"Apache 2.0\",\"url\":\"https://www.apache.org/licenses/LICENSE-2.0.html\"}},\"servers\":[{\"url\":\"http://localhost:5000/api/v1\",\"description\":\"Development server\"},{\"url\":\"https://yourdomain.com/api/v1\",\"description\":\"Production server\"},{\"url\":\"{protocol}://{host}/api/v1\",\"description\":\"Custom server\",\"variables\":{\"protocol\":{\"enum\":[\"http\",\"https\"],\"default\":\"https\"},\"host\":{\"default\":\"yourdomain.com\",\"description\":\"Your changedetection.io host\"}}}],\"security\":[{\"ApiKeyAuth\":[]}],\"tags\":[{\"name\":\"Watch Management\",\"description\":\"Core functionality for managing web page monitors. Create, retrieve, update, and delete individual watches. \\nEach watch represents a single URL being monitored for changes, with configurable settings for check intervals, \\nnotification preferences, and content filtering options.\\n\"},{\"name\":\"Watch History\",\"description\":\"Get a list of timestamps of all changes detected for a watch.\\n\"},{\"name\":\"Snapshots\",\"description\":\"Retrieve individual text snapshot of monitored content according to the `timestamp`. The text snapshot is the HTML\\nto Text at page check time. \\n\\nSet the query argument `html` to any value to retrieve the last HTML fetched, the system only keeps the last two \\n(2) HTML files fetched.\\n\\nUse the Watch History API endpoint to get a list of timestamps to pass to this query.\\n\"},{\"name\":\"Favicon\",\"description\":\"Retrieve favicon images associated with monitored web pages. These are used in the dashboard interface \\nto visually identify different watches in your monitoring list.\\n\"},{\"name\":\"Group / Tag Management\",\"description\":\"Organize your watches using tags and groups. Tags (also known as Groups) allow you to categorize monitors, set group-wide \\nnotification preferences, and perform bulk operations like mass rechecking or status changes across \\nmultiple related watches.\\n\"},{\"name\":\"Notifications\",\"description\":\"Configure global notification endpoints that can be used across all your watches. Supports various \\nnotification services including email, Discord, Slack, webhooks, and many other popular platforms. \\nThese settings serve as defaults that can be overridden at the individual watch or tag level.\\n\\nThe notification syntax uses [https://github.com/caronc/apprise](https://github.com/caronc/apprise).\\n\"},{\"name\":\"Search\",\"description\":\"Search and filter your watches by URL patterns, titles, or tags. Useful for quickly finding specific \\nmonitors in large collections or identifying watches that match certain criteria.\\n\"},{\"name\":\"Import\",\"description\":\"Bulk import multiple URLs for monitoring. Accepts plain text lists of URLs and can automatically \\napply tags, proxy settings, and other configurations to all imported watches simultaneously.\\n\"},{\"name\":\"System Information\",\"description\":\"Retrieve system status and statistics about your changedetection.io instance, including total watch\\ncounts, uptime information, and version details.\\n\"},{\"name\":\"Plugin API Extensions\",\"description\":\"## How Processor Plugins Extend the API\\n\\nchangedetection.io uses a **processor plugin** system to handle different types of change detection.\\nEach processor lives in `changedetectionio/processors/<name>/` and may include an `api.yaml` file\\nthat extends the core Watch schema with processor-specific configuration fields.\\n\\n### How it works\\n\\nAt startup, changedetection.io scans all installed processors for an `api.yaml` file. Any schemas\\nand code samples defined there are deep-merged into the live API specification, making the\\nprocessor's configuration fields valid on all watch create and update requests.\\n\\nThe live, fully-merged spec is always available at `/api/v1/full-spec` — use that URL with\\nSwagger UI or Redoc to see the complete schema for your specific installation.\\n\\n---\\n\\n### Writing a processor `api.yaml`\\n\\nPlace an `api.yaml` in the processor plugin's own directory, alongside its `__init__.py`\\n(e.g. `changedetectionio/processors/my_processor/api.yaml`). The schema name **must** follow the\\nconvention `processor_config_<processor_name>` (e.g. `processor_config_restock_diff`). That same\\nkey is used as the JSON field name when creating or updating a watch.\\n\\nA minimal `api.yaml` for a hypothetical `my_processor`:\\n\\n```yaml\\ncomponents:\\n  schemas:\\n    processor_config_my_processor:\\n      type: object\\n      description: Configuration for my_processor\\n      properties:\\n        some_option:\\n          type: boolean\\n          default: true\\n          description: Enable some behaviour\\n\\npaths:\\n  /watch:\\n    post:\\n      x-code-samples:\\n        - lang: curl\\n          label: my_processor example\\n          source: |\\n            curl -X POST \\\"http://localhost:5000/api/v1/watch\\\" \\\\\\n              -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n              -H \\\"Content-Type: application/json\\\" \\\\\\n              -d '{\\n                \\\"url\\\": \\\"https://example.com\\\",\\n                \\\"processor\\\": \\\"my_processor\\\",\\n                \\\"processor_config_my_processor\\\": { \\\"some_option\\\": true }\\n              }'\\n```\\n\\nThe `paths` section in `api.yaml` is used only for injecting additional `x-code-samples` into\\nexisting endpoints — you cannot define new routes via plugin.\\n\\n---\\n\\n### Built-in plugin: `restock_diff`\\n\\nThe `restock_diff` processor is always shipped with changedetection.io. It monitors product\\navailability and price changes using structured data (JSON-LD / schema.org microdata) and\\ntext heuristics. It is activated by setting `\\\"processor\\\": \\\"restock_diff\\\"` on a watch.\\n\\nIt adds the `processor_config_restock_diff` block to the Watch schema with these fields:\\n\\n| Field | Type | Default | Description |\\n|---|---|---|---|\\n| `in_stock_processing` | string | `in_stock_only` | `in_stock_only` — only alert Out-of-Stock→In-Stock · `all_changes` — alert any availability change · `off` — disable stock tracking |\\n| `follow_price_changes` | boolean | `true` | Monitor and alert on price changes |\\n| `price_change_min` | number\\\\|null | — | Alert when price drops **below** this value |\\n| `price_change_max` | number\\\\|null | — | Alert when price rises **above** this value |\\n| `price_change_threshold_percent` | number\\\\|null | — | Minimum % change since the original price to trigger an alert |\\n\\n#### CREATE — Add a restock/price monitor\\n\\n```bash\\ncurl -X POST \\\"http://localhost:5000/api/v1/watch\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"url\\\": \\\"https://example.com/product/widget\\\",\\n    \\\"processor\\\": \\\"restock_diff\\\",\\n    \\\"processor_config_restock_diff\\\": {\\n      \\\"in_stock_processing\\\": \\\"in_stock_only\\\",\\n      \\\"follow_price_changes\\\": true,\\n      \\\"price_change_threshold_percent\\\": 5\\n    }\\n  }'\\n```\\n\\n#### READ — Retrieve the monitor\\n\\nThe response JSON includes `processor_config_restock_diff` alongside all standard watch fields:\\n\\n```bash\\ncurl -X GET \\\"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n```\\n\\n```json\\n{\\n  \\\"uuid\\\": \\\"cc0cfffa-f449-477b-83ea-0caafd1dc091\\\",\\n  \\\"url\\\": \\\"https://example.com/product/widget\\\",\\n  \\\"processor\\\": \\\"restock_diff\\\",\\n  \\\"processor_config_restock_diff\\\": {\\n    \\\"in_stock_processing\\\": \\\"in_stock_only\\\",\\n    \\\"follow_price_changes\\\": true,\\n    \\\"price_change_threshold_percent\\\": 5,\\n    \\\"price_change_min\\\": null,\\n    \\\"price_change_max\\\": null\\n  }\\n}\\n```\\n\\n#### UPDATE — Change thresholds without recreating the monitor\\n\\nOnly fields included in the request body are updated; omitted fields are left unchanged.\\n\\n```bash\\ncurl -X PUT \\\"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"processor_config_restock_diff\\\": {\\n      \\\"in_stock_processing\\\": \\\"all_changes\\\",\\n      \\\"follow_price_changes\\\": true,\\n      \\\"price_change_min\\\": 10.00,\\n      \\\"price_change_max\\\": 500.00\\n    }\\n  }'\\n```\\n\\n#### DELETE — Remove the monitor\\n\\n```bash\\ncurl -X DELETE \\\"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n```\\n\\n---\\n\\nFor the complete schema-validated documentation including all processor fields, fetch the live spec\\nand load it into Swagger UI or Redoc:\\n\\n```\\nGET /api/v1/full-spec\\n```\\n\"}],\"components\":{\"securitySchemes\":{\"ApiKeyAuth\":{\"type\":\"apiKey\",\"in\":\"header\",\"name\":\"x-api-key\",\"description\":\"API key for authentication. You can find your API key in the changedetection.io dashboard under Settings > API.\\n\\nEnter your API key in the \\\"Authorize\\\" button above to automatically populate all code examples.\\n\"}},\"schemas\":{\"WatchBase\":{\"type\":\"object\",\"properties\":{\"uuid\":{\"type\":\"string\",\"format\":\"uuid\",\"description\":\"Unique identifier\",\"readOnly\":true},\"date_created\":{\"type\":[\"integer\",\"null\"],\"description\":\"Unix timestamp of creation\",\"readOnly\":true},\"url\":{\"type\":\"string\",\"format\":\"uri\",\"description\":\"URL to monitor for changes\",\"maxLength\":5000},\"title\":{\"type\":[\"string\",\"null\"],\"description\":\"Custom title for the web page change monitor (watch), not to be confused with page_title\",\"maxLength\":5000},\"tag\":{\"type\":\"string\",\"description\":\"Tag UUID to associate with this web page change monitor (watch)\",\"maxLength\":5000},\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Array of tag UUIDs\"},\"paused\":{\"type\":\"boolean\",\"description\":\"Whether the web page change monitor (watch) is paused\"},\"notification_muted\":{\"type\":\"boolean\",\"description\":\"Whether notifications are muted\"},\"method\":{\"type\":\"string\",\"enum\":[\"GET\",\"POST\",\"DELETE\",\"PUT\"],\"description\":\"HTTP method to use\"},\"fetch_backend\":{\"type\":\"string\",\"description\":\"Backend to use for fetching content. Common values:\\n- `system` (default) - Use the system-wide default fetcher\\n- `html_requests` - Fast requests-based fetcher\\n- `html_webdriver` - Browser-based fetcher (Playwright/Puppeteer)\\n- `extra_browser_*` - Custom browser configurations (if configured)\\n- Plugin-provided fetchers (if installed)\\n\",\"pattern\":\"^(system|html_requests|html_webdriver|extra_browser_.+)$\",\"default\":\"system\"},\"headers\":{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"},\"description\":\"HTTP headers to include in requests\"},\"body\":{\"type\":[\"string\",\"null\"],\"description\":\"HTTP request body\",\"maxLength\":5000},\"proxy\":{\"type\":[\"string\",\"null\"],\"description\":\"Proxy configuration\",\"maxLength\":5000},\"ignore_status_codes\":{\"type\":[\"boolean\",\"null\"],\"description\":\"Ignore HTTP status code errors (boolean or null)\"},\"webdriver_delay\":{\"type\":[\"integer\",\"null\"],\"description\":\"Delay in seconds for webdriver\"},\"webdriver_js_execute_code\":{\"type\":[\"string\",\"null\"],\"description\":\"JavaScript code to execute\",\"maxLength\":5000},\"time_between_check\":{\"type\":\"object\",\"properties\":{\"weeks\":{\"type\":[\"integer\",\"null\"],\"minimum\":0,\"maximum\":52000},\"days\":{\"type\":[\"integer\",\"null\"],\"minimum\":0,\"maximum\":365000},\"hours\":{\"type\":[\"integer\",\"null\"],\"minimum\":0,\"maximum\":8760000},\"minutes\":{\"type\":[\"integer\",\"null\"],\"minimum\":0,\"maximum\":525600000},\"seconds\":{\"type\":[\"integer\",\"null\"],\"minimum\":0,\"maximum\":31536000000}},\"description\":\"Time intervals between checks. All fields must be non-negative. At least one non-zero value required when not using default settings.\"},\"time_between_check_use_default\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Whether to use global settings for time between checks - defaults to true if not set\"},\"notification_urls\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"maxLength\":1000},\"maxItems\":100,\"description\":\"Notification URLs for this web page change monitor (watch). Maximum 100 URLs.\"},\"notification_title\":{\"type\":[\"string\",\"null\"],\"description\":\"Custom notification title\",\"maxLength\":5000},\"notification_body\":{\"type\":[\"string\",\"null\"],\"description\":\"Custom notification body\",\"maxLength\":5000},\"notification_format\":{\"type\":\"string\",\"enum\":[\"text\",\"html\",\"htmlcolor\",\"markdown\",\"System default\"],\"description\":\"Format for notifications\"},\"track_ldjson_price_data\":{\"type\":[\"boolean\",\"null\"],\"description\":\"Whether to track JSON-LD price data\"},\"browser_steps\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"operation\":{\"type\":[\"string\",\"null\"],\"maxLength\":5000},\"selector\":{\"type\":[\"string\",\"null\"],\"maxLength\":5000},\"optional_value\":{\"type\":[\"string\",\"null\"],\"maxLength\":5000}},\"required\":[\"operation\",\"selector\",\"optional_value\"],\"additionalProperties\":false},\"maxItems\":100,\"description\":\"Browser automation steps. Maximum 100 steps allowed.\"},\"processor\":{\"type\":\"string\",\"enum\":[\"restock_diff\",\"text_json_diff\"],\"default\":\"text_json_diff\",\"description\":\"Optional processor mode to use for change detection. Defaults to `text_json_diff` if not specified.\"},\"include_filters\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"maxLength\":5000},\"maxItems\":100,\"description\":\"CSS/XPath selectors to extract specific content from the page\"},\"subtractive_selectors\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"maxLength\":5000},\"maxItems\":100,\"description\":\"CSS/XPath selectors to remove content from the page\"},\"ignore_text\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"maxLength\":5000},\"maxItems\":100,\"description\":\"Text patterns to ignore in change detection\"},\"trigger_text\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"maxLength\":5000},\"maxItems\":100,\"description\":\"Text/regex patterns that must be present to trigger a change\"},\"text_should_not_be_present\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"maxLength\":5000},\"maxItems\":100,\"description\":\"Text that should NOT be present (triggers alert if found)\"},\"extract_text\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"maxLength\":5000},\"maxItems\":100,\"description\":\"Regex patterns to extract specific text after filtering\"},\"trim_text_whitespace\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Strip leading/trailing whitespace from text\"},\"sort_text_alphabetically\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Sort lines alphabetically before comparison\"},\"remove_duplicate_lines\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Remove duplicate lines from content\"},\"check_unique_lines\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Compare against all history for unique lines\"},\"strip_ignored_lines\":{\"type\":[\"boolean\",\"null\"],\"description\":\"Remove lines matching ignore patterns\"},\"filter_text_added\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Include added text in change detection\"},\"filter_text_removed\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Include removed text in change detection\"},\"filter_text_replaced\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Include replaced text in change detection\"},\"in_stock_only\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Only trigger on in-stock transitions (restock_diff processor)\"},\"follow_price_changes\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Monitor and track price changes (restock_diff processor)\"},\"price_change_threshold_percent\":{\"type\":[\"number\",\"null\"],\"description\":\"Minimum price change percentage to trigger notification\"},\"has_ldjson_price_data\":{\"type\":[\"boolean\",\"null\"],\"description\":\"Whether page has LD-JSON price data (auto-detected)\",\"readOnly\":true},\"notification_screenshot\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Include screenshot in notifications (if supported by notification URL)\"},\"filter_failure_notification_send\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Send notification when filters fail to match content\"},\"use_page_title_in_list\":{\"type\":[\"boolean\",\"null\"],\"description\":\"Display page title in watch list (null = use system default)\"},\"history_snapshot_max_length\":{\"type\":[\"integer\",\"null\"],\"minimum\":1,\"maximum\":1000,\"description\":\"Maximum number of history snapshots to keep (null = use system default)\"},\"time_schedule_limit\":{\"type\":\"object\",\"description\":\"Weekly schedule limiting when checks can run\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":false},\"monday\":{\"$ref\":\"#/components/schemas/DaySchedule\"},\"tuesday\":{\"$ref\":\"#/components/schemas/DaySchedule\"},\"wednesday\":{\"$ref\":\"#/components/schemas/DaySchedule\"},\"thursday\":{\"$ref\":\"#/components/schemas/DaySchedule\"},\"friday\":{\"$ref\":\"#/components/schemas/DaySchedule\"},\"saturday\":{\"$ref\":\"#/components/schemas/DaySchedule\"},\"sunday\":{\"$ref\":\"#/components/schemas/DaySchedule\"}}},\"conditions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"field\":{\"type\":\"string\",\"description\":\"Field to check (e.g., 'page_filtered_text', 'page_title')\"},\"operator\":{\"type\":\"string\",\"description\":\"Comparison operator (e.g., 'contains_regex', 'equals', 'not_equals')\"},\"value\":{\"type\":\"string\",\"description\":\"Value to compare against\"}},\"required\":[\"field\",\"operator\",\"value\"]},\"maxItems\":100,\"description\":\"Array of condition rules for change detection logic (empty array when not set)\"},\"conditions_match_logic\":{\"type\":\"string\",\"enum\":[\"ALL\",\"ANY\"],\"default\":\"ALL\",\"description\":\"Logic operator - ALL (match all conditions) or ANY (match any condition)\"}}},\"DaySchedule\":{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true},\"start_time\":{\"type\":\"string\",\"pattern\":\"^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$\",\"default\":\"00:00\",\"description\":\"Start time in HH:MM format\"},\"duration\":{\"type\":\"object\",\"properties\":{\"hours\":{\"type\":\"string\",\"pattern\":\"^[0-9]+$\",\"default\":\"24\"},\"minutes\":{\"type\":\"string\",\"pattern\":\"^[0-9]+$\",\"default\":\"00\"}}}}},\"Watch\":{\"allOf\":[{\"$ref\":\"#/components/schemas/WatchBase\"},{\"type\":\"object\",\"properties\":{\"last_checked\":{\"type\":\"integer\",\"description\":\"Unix timestamp of last check\",\"readOnly\":true},\"last_changed\":{\"type\":\"integer\",\"description\":\"Unix timestamp of last change\",\"readOnly\":true,\"x-computed\":true},\"last_error\":{\"type\":[\"string\",\"boolean\",\"null\"],\"description\":\"Last error message (false when no error, string when error occurred, null if not checked yet)\",\"readOnly\":true},\"last_viewed\":{\"type\":\"integer\",\"description\":\"Unix timestamp in seconds of the last time the watch was viewed. Setting it to a value higher than `last_changed` in the \\\"Update watch\\\" endpoint marks the watch as viewed.\",\"minimum\":0},\"link\":{\"type\":\"string\",\"format\":\"string\",\"description\":\"The watch URL rendered in case of any Jinja2 markup, always use this for listing.\",\"readOnly\":true,\"x-computed\":true},\"page_title\":{\"type\":[\"string\",\"null\"],\"description\":\"HTML <title> tag extracted from the page\",\"readOnly\":true},\"check_count\":{\"type\":\"integer\",\"description\":\"Total number of checks performed\",\"readOnly\":true},\"fetch_time\":{\"type\":\"number\",\"description\":\"Duration of last fetch in seconds\",\"readOnly\":true},\"previous_md5\":{\"type\":[\"string\",\"boolean\"],\"description\":\"MD5 hash of previous content (false if not set)\",\"readOnly\":true},\"previous_md5_before_filters\":{\"type\":[\"string\",\"boolean\"],\"description\":\"MD5 hash before filters applied (false if not set)\",\"readOnly\":true},\"consecutive_filter_failures\":{\"type\":\"integer\",\"description\":\"Counter for consecutive filter match failures\",\"readOnly\":true},\"last_notification_error\":{\"type\":[\"string\",\"null\"],\"description\":\"Last notification error message\",\"readOnly\":true},\"notification_alert_count\":{\"type\":\"integer\",\"description\":\"Number of notifications sent\",\"readOnly\":true},\"content-type\":{\"type\":[\"string\",\"null\"],\"description\":\"Content-Type from last fetch\",\"readOnly\":true},\"remote_server_reply\":{\"type\":[\"string\",\"null\"],\"description\":\"Server header from last response\",\"readOnly\":true},\"browser_steps_last_error_step\":{\"type\":[\"integer\",\"null\"],\"description\":\"Last browser step that caused an error\",\"readOnly\":true},\"viewed\":{\"type\":[\"integer\",\"boolean\"],\"description\":\"Computed property - true if watch has been viewed, false otherwise (deprecated, use last_viewed instead)\",\"readOnly\":true,\"x-computed\":true},\"history_n\":{\"type\":\"integer\",\"description\":\"Number of history snapshots available\",\"readOnly\":true,\"x-computed\":true}}}]},\"CreateWatch\":{\"allOf\":[{\"$ref\":\"#/components/schemas/WatchBase\"},{\"type\":\"object\",\"required\":[\"url\"]}]},\"UpdateWatch\":{\"allOf\":[{\"$ref\":\"#/components/schemas/WatchBase\"},{\"type\":\"object\",\"properties\":{\"last_viewed\":{\"type\":\"integer\",\"description\":\"Unix timestamp in seconds of the last time the watch was viewed. Setting it to a value higher than `last_changed` in the \\\"Update watch\\\" endpoint marks the watch as viewed.\",\"minimum\":0}}}]},\"Tag\":{\"allOf\":[{\"$ref\":\"#/components/schemas/WatchBase\"},{\"type\":\"object\",\"properties\":{\"overrides_watch\":{\"type\":[\"boolean\",\"null\"],\"description\":\"Whether this tag's settings override watch settings for all watches in this tag/group.\\n- true: Tag settings override watch settings\\n- false: Tag settings do not override (watches use their own settings)\\n- null: Not decided yet / inherit default behavior\\n\"}}}]},\"CreateTag\":{\"allOf\":[{\"$ref\":\"#/components/schemas/Tag\"},{\"type\":\"object\",\"required\":[\"title\"]}]},\"NotificationUrls\":{\"type\":\"object\",\"properties\":{\"notification_urls\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uri\"},\"description\":\"List of notification URLs\"}},\"required\":[\"notification_urls\"]},\"SystemInfo\":{\"type\":\"object\",\"properties\":{\"watch_count\":{\"type\":\"integer\",\"description\":\"Total number of web page change monitors (watches)\"},\"tag_count\":{\"type\":\"integer\",\"description\":\"Total number of tags\"},\"uptime\":{\"type\":\"string\",\"description\":\"System uptime\"},\"version\":{\"type\":\"string\",\"description\":\"Application version\"}}},\"SearchResult\":{\"type\":\"object\",\"properties\":{\"watches\":{\"type\":\"object\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/Watch\"},\"description\":\"Dictionary of matching web page change monitors (watches) keyed by UUID\"}}},\"WatchHistory\":{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\",\"description\":\"Path to snapshot file\"},\"description\":\"Dictionary of timestamps and snapshot paths\"},\"Error\":{\"type\":\"object\",\"properties\":{\"message\":{\"type\":\"string\",\"description\":\"Error message\"}}}}},\"paths\":{\"/watch\":{\"get\":{\"operationId\":\"listWatches\",\"tags\":[\"Watch Management\"],\"summary\":\"List all watches\",\"description\":\"Return concise list of available web page change monitors (watches) and basic info\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/watch\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nresponse = requests.get('http://localhost:5000/api/v1/watch', headers=headers)\\nprint(response.json())\\n\"}],\"parameters\":[{\"name\":\"recheck_all\",\"in\":\"query\",\"description\":\"Set to 1 to force recheck of all watches\",\"schema\":{\"type\":\"string\",\"enum\":[\"1\"]}},{\"name\":\"tag\",\"in\":\"query\",\"description\":\"Tag name to filter results\",\"schema\":{\"type\":\"string\"}}],\"responses\":{\"200\":{\"description\":\"List of watches\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/Watch\"}},\"example\":{\"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\":{\"uuid\":\"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\",\"url\":\"http://example.com?id={{1+1}} - the raw URL\",\"link\":\"http://example.com?id=2 - the rendered URL, always use this for listing.\",\"title\":\"Example Website Monitor - manually entered title/description\",\"page_title\":\"The HTML <title> from the page\",\"tags\":[\"550e8400-e29b-41d4-a716-446655440000\"],\"paused\":false,\"notification_muted\":false,\"method\":\"GET\",\"fetch_backend\":\"html_requests\",\"last_checked\":1640995200,\"last_changed\":1640995200},\"7c9e6b8d-f2a1-4e5c-9d3b-8a7f6e4c2d1a\":{\"uuid\":\"7c9e6b8d-f2a1-4e5c-9d3b-8a7f6e4c2d1a\",\"url\":\"http://example.com?id={{1+1}} - the raw URL\",\"link\":\"http://example.com?id=2 - the rendered URL, always use this for listing.\",\"title\":\"News Site Tracker - manually entered title/description\",\"page_title\":\"The HTML <title> from the page\",\"tags\":[\"330e8400-e29b-41d4-a716-446655440001\"],\"paused\":false,\"notification_muted\":true,\"method\":\"GET\",\"fetch_backend\":\"html_webdriver\",\"last_checked\":1640998800,\"last_changed\":1640995200}}}}}}},\"post\":{\"operationId\":\"createWatch\",\"tags\":[\"Watch Management\"],\"summary\":\"Create a new watch\",\"description\":\"Create a single web page change monitor (watch). Requires at least `url` to be set.\\n\\nEvery watch can be configured with:\\n- **Processor mode**: `processor` field (`restock_diff` or `text_json_diff` - default)\\n- **Notification settings**: `notification_urls` (array), `notification_title`, `notification_body`, `notification_format`, `notification_muted`\\n- **Tags/Groups**: `tag` (UUID string) or `tags` (array of UUIDs)\\n- **Check settings**: `time_between_check`, `paused`, `method`, `fetch_backend`\\n- **Advanced options**: `headers`, `body`, `proxy`, `browser_steps`, and more\\n\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X POST \\\"http://localhost:5000/api/v1/watch\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"url\\\": \\\"https://example.com\\\",\\n    \\\"title\\\": \\\"Example Site Monitor\\\",\\n    \\\"time_between_check\\\": {\\n      \\\"hours\\\": 1\\n    }\\n  }'\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\nimport json\\n\\nheaders = {\\n    'x-api-key': 'YOUR_API_KEY',\\n    'Content-Type': 'application/json'\\n}\\ndata = {\\n    'url': 'https://example.com',\\n    'title': 'Example Site Monitor',\\n    'time_between_check': {\\n        'hours': 1\\n    }\\n}\\nresponse = requests.post('http://localhost:5000/api/v1/watch',\\n                       headers=headers, json=data)\\nprint(response.text)\\n\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/CreateWatch\"},\"example\":{\"url\":\"https://example.com\",\"title\":\"Example Site Monitor\",\"time_between_check\":{\"hours\":1}}}}},\"responses\":{\"200\":{\"description\":\"Web page change monitor (watch) created successfully\",\"content\":{\"text/plain\":{\"schema\":{\"type\":\"string\",\"example\":\"OK\"}}}},\"500\":{\"description\":\"Server error\",\"content\":{\"text/plain\":{\"schema\":{\"type\":\"string\"}}}}}}},\"/watch/{uuid}\":{\"get\":{\"operationId\":\"getWatch\",\"tags\":[\"Watch Management\"],\"summary\":\"Get single watch\",\"description\":\"Retrieve web page change monitor (watch) information and set muted/paused status. Returns the FULL Watch JSON.\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nuuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\\nresponse = requests.get(f'http://localhost:5000/api/v1/watch/{uuid}', headers=headers)\\nprint(response.json())\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Web page change monitor (watch) unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}},{\"name\":\"recheck\",\"in\":\"query\",\"description\":\"Recheck this web page change monitor (watch)\",\"schema\":{\"type\":\"string\",\"enum\":[\"1\",\"true\"]}},{\"name\":\"paused\",\"in\":\"query\",\"description\":\"Set pause state\",\"schema\":{\"type\":\"string\",\"enum\":[\"paused\",\"unpaused\"]}},{\"name\":\"muted\",\"in\":\"query\",\"description\":\"Set mute state\",\"schema\":{\"type\":\"string\",\"enum\":[\"muted\",\"unmuted\"]}}],\"responses\":{\"200\":{\"description\":\"Watch information or operation result\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Watch\"}},\"text/plain\":{\"schema\":{\"type\":\"string\",\"example\":\"OK\"}}}},\"404\":{\"description\":\"Web page change monitor (watch) not found\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Error\"}}}}}},\"put\":{\"operationId\":\"updateWatch\",\"tags\":[\"Watch Management\"],\"summary\":\"Update watch\",\"description\":\"Update an existing web page change monitor (watch) using JSON. Accepts the same structure as returned in [get single watch information](#operation/getWatch).\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X PUT \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"url\\\": \\\"https://updated-example.com\\\",\\n    \\\"title\\\": \\\"Updated Monitor\\\",\\n    \\\"paused\\\": false\\n  }'\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {\\n    'x-api-key': 'YOUR_API_KEY',\\n    'Content-Type': 'application/json'\\n}\\nuuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\\ndata = {\\n    'url': 'https://updated-example.com',\\n    'title': 'Updated Monitor',\\n    'paused': False\\n}\\nresponse = requests.put(f'http://localhost:5000/api/v1/watch/{uuid}', \\n                      headers=headers, json=data)\\nprint(response.text)\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Web page change monitor (watch) unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UpdateWatch\"}}}},\"responses\":{\"200\":{\"description\":\"Web page change monitor (watch) updated successfully\",\"content\":{\"text/plain\":{\"schema\":{\"type\":\"string\",\"example\":\"OK\"}}}},\"500\":{\"description\":\"Server error\"}}},\"delete\":{\"operationId\":\"deleteWatch\",\"tags\":[\"Watch Management\"],\"summary\":\"Delete watch\",\"description\":\"Delete a web page change monitor (watch) and all related history\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X DELETE \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nuuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\\nresponse = requests.delete(f'http://localhost:5000/api/v1/watch/{uuid}', headers=headers)\\nprint(response.text)\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Web page change monitor (watch) unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"200\":{\"description\":\"Web page change monitor (watch) deleted successfully\",\"content\":{\"text/plain\":{\"schema\":{\"type\":\"string\",\"example\":\"OK\"}}}}}}},\"/watch/{uuid}/history\":{\"get\":{\"operationId\":\"getWatchHistory\",\"tags\":[\"Watch History\"],\"summary\":\"Get watch history\",\"description\":\"Get a list of all historical snapshots available for a web page change monitor (watch), use the key `timestamp`\\nas the query argument for fetching a single watch history snapshot.\\n\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/history\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nuuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\\nresponse = requests.get(f'http://localhost:5000/api/v1/watch/{uuid}/history', headers=headers)\\nprint(response.json())\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Web page change monitor (watch) unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"200\":{\"description\":\"List of available snapshots\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WatchHistory\"},\"example\":{\"1640995200\":\"/path/to/snapshot1.txt\",\"1640998800\":\"/path/to/snapshot2.txt\"}}}},\"404\":{\"description\":\"Web page change monitor (watch) not found\"}}}},\"/watch/{uuid}/history/{timestamp}\":{\"get\":{\"operationId\":\"getWatchSnapshot\",\"tags\":[\"Snapshots\"],\"summary\":\"Get single snapshot\",\"description\":\"Get single snapshot from web page change monitor (watch). Use 'latest' for the most recent snapshot.\\nUse the Watch History API to get a list of timestamps to pass.\\n\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/history/latest\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nuuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\\ntimestamp = 'latest'  # or use specific timestamp like 1640995200\\nresponse = requests.get(f'http://localhost:5000/api/v1/watch/{uuid}/history/{timestamp}', headers=headers)\\nprint(response.text)\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Web page change monitor (watch) unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}},{\"name\":\"timestamp\",\"in\":\"path\",\"required\":true,\"description\":\"Snapshot timestamp or 'latest'\",\"schema\":{\"oneOf\":[{\"type\":\"integer\"},{\"type\":\"string\",\"enum\":[\"latest\"]}]}},{\"name\":\"html\",\"in\":\"query\",\"description\":\"Set to 1 to return the last HTML\",\"schema\":{\"type\":\"string\",\"enum\":[\"1\"]}}],\"responses\":{\"200\":{\"description\":\"Snapshot content\",\"content\":{\"text/plain\":{\"schema\":{\"type\":\"string\"}}}},\"404\":{\"description\":\"Snapshot not found\"}}}},\"/watch/{uuid}/difference/{from_timestamp}/{to_timestamp}\":{\"get\":{\"operationId\":\"getWatchHistoryDiff\",\"tags\":[\"Watch History\"],\"summary\":\"Get the difference between two snapshots\",\"description\":\"Generate a difference (comparison) between two historical snapshots of a web page change monitor (watch).\\n\\nThis endpoint compares content between two points in time and returns the differences in your chosen format.\\nPerfect for reviewing what changed between specific versions or comparing recent changes.\\n\\n**Timestamp Keywords:**\\n- Use `'latest'` for the most recent snapshot (to_timestamp)\\n- Use `'previous'` for the second-most-recent snapshot (from_timestamp)\\n- Or use specific Unix timestamps from the watch history\\n\\n**Format Options:**\\n- `text` (default): Plain text with (removed) and (added) prefixes\\n- `html`: HTML format with (removed) and (added) text\\n- `htmlcolor`: Rich HTML with colored highlights (green for additions, red for deletions)\\n\\n**Word-Level Diffing:**\\n- Enable word-level granularity with `word_diff=true` for detailed inline comparisons\\n- Disable with `word_diff=false` for line-level comparisons only (default false/off, line-level mode by default)\\n\\n**Raw Diff Output:**\\n- Use `no_markup=true` to get raw diff content without any formatting applied\\n- Returns content with placeholders for opening/closing tags of changes\\n- Allows you to implement your own custom colorisation or formatting\\n- Skips all HTML color application and service tweaks (added text, html color tags, etc)\\n\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"# Compare previous snapshot to latest with colored HTML\\ncurl -X GET \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/previous/latest?format=htmlcolor\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\\n# Compare two specific timestamps in plain text with word-level diff\\ncurl -X GET \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/1640995200/1640998800?format=text&word_diff=true\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\\n# Show only additions (hide removed/replaced content), ignore whitespace\\ncurl -X GET \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/difference/previous/latest?format=htmlcolor&removed=false&replaced=false&ignoreWhitespace=true\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nuuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\\n\\n# Compare previous to latest with colored HTML output\\nresponse = requests.get(\\n    f'http://localhost:5000/api/v1/watch/{uuid}/difference/previous/latest',\\n    headers=headers,\\n    params={'format': 'htmlcolor'}\\n)\\nprint(response.text)\\n\\n# Compare specific timestamps with word-level diff\\nfrom_ts = '1640995200'\\nto_ts = '1640998800'\\nresponse = requests.get(\\n    f'http://localhost:5000/api/v1/watch/{uuid}/difference/{from_ts}/{to_ts}',\\n    headers=headers,\\n    params={'format': 'text', 'word_diff': 'true'}\\n)\\nprint(response.text)\\n\\n# Show only additions, ignore whitespace and use word-level diff\\nresponse = requests.get(\\n    f'http://localhost:5000/api/v1/watch/{uuid}/difference/previous/latest',\\n    headers=headers,\\n    params={\\n        'format': 'htmlcolor',\\n        'type': 'diffWords',\\n        'removed': 'false',\\n        'replaced': 'false',\\n        'ignoreWhitespace': 'true'\\n    }\\n)\\nprint(response.text)\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Web page change monitor (watch) unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}},{\"name\":\"from_timestamp\",\"in\":\"path\",\"required\":true,\"description\":\"Starting snapshot timestamp, 'previous' for second-most-recent, or specific Unix timestamp\",\"schema\":{\"oneOf\":[{\"type\":\"integer\",\"description\":\"Unix timestamp of the starting snapshot\"},{\"type\":\"string\",\"enum\":[\"previous\"],\"description\":\"Use 'previous' to automatically select the second-most-recent snapshot\"}]},\"example\":\"previous\"},{\"name\":\"to_timestamp\",\"in\":\"path\",\"required\":true,\"description\":\"Ending snapshot timestamp, 'latest' for most recent, or specific Unix timestamp\",\"schema\":{\"oneOf\":[{\"type\":\"integer\",\"description\":\"Unix timestamp of the ending snapshot\"},{\"type\":\"string\",\"enum\":[\"latest\"],\"description\":\"Use 'latest' to automatically select the most recent snapshot\"}]},\"example\":\"latest\"},{\"name\":\"format\",\"in\":\"query\",\"description\":\"Output format for the diff:\\n- `text` (default): Plain text with (removed) and (added) prefixes\\n- `html`: Basic HTML format\\n- `htmlcolor`: Rich HTML with colored backgrounds (red for deletions, green for additions)\\n- `markdown`: Markdown format with HTML rendering\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"text\",\"html\",\"htmlcolor\",\"markdown\"],\"default\":\"text\"}},{\"name\":\"word_diff\",\"in\":\"query\",\"description\":\"Enable word-level diffing for more granular comparisons.\\nWhen enabled, changes are highlighted at the word level rather than line level.\\nDefault is false (line-level mode).\\nAccepts: true, false, 1, 0, yes, no, on, off\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"true\",\"false\",\"1\",\"0\",\"yes\",\"no\",\"on\",\"off\"],\"default\":\"false\"}},{\"name\":\"no_markup\",\"in\":\"query\",\"description\":\"When set to true, returns the raw diff content without any markup formatting.\\nThe content will include placeholders for opening/closing tags of the changes,\\nallowing you to implement your own custom colorisation or formatting.\\nThis skips all HTML color application and service tweaks.\\nAccepts: true, false, 1, 0, yes, no, on, off\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"true\",\"false\",\"1\",\"0\",\"yes\",\"no\",\"on\",\"off\"],\"default\":\"false\"}},{\"name\":\"type\",\"in\":\"query\",\"description\":\"Diff granularity type:\\n- `diffLines` (default): Line-level comparison, showing which lines changed\\n- `diffWords`: Word-level comparison, showing which words changed within lines\\n\\nThis parameter is an alternative to `word_diff` for better alignment with the UI.\\nIf both are specified, `type=diffWords` will enable word-level diffing.\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"diffLines\",\"diffWords\"],\"default\":\"diffLines\"}},{\"name\":\"changesOnly\",\"in\":\"query\",\"description\":\"When enabled, only show lines/content that changed (no surrounding context).\\nWhen disabled, include unchanged lines for context around changes.\\nAccepts: true, false, 1, 0, yes, no, on, off\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"true\",\"false\",\"1\",\"0\",\"yes\",\"no\",\"on\",\"off\"],\"default\":\"true\"}},{\"name\":\"ignoreWhitespace\",\"in\":\"query\",\"description\":\"When enabled, ignore whitespace-only changes (spaces, tabs, newlines).\\nUseful for focusing on content changes and ignoring formatting differences.\\nAccepts: true, false, 1, 0, yes, no, on, off\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"true\",\"false\",\"1\",\"0\",\"yes\",\"no\",\"on\",\"off\"],\"default\":\"false\"}},{\"name\":\"removed\",\"in\":\"query\",\"description\":\"Include removed/deleted content in the diff output.\\nWhen disabled, content that was deleted will not appear in the diff.\\nAccepts: true, false, 1, 0, yes, no, on, off\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"true\",\"false\",\"1\",\"0\",\"yes\",\"no\",\"on\",\"off\"],\"default\":\"true\"}},{\"name\":\"added\",\"in\":\"query\",\"description\":\"Include added/new content in the diff output.\\nWhen disabled, content that was added will not appear in the diff.\\nAccepts: true, false, 1, 0, yes, no, on, off\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"true\",\"false\",\"1\",\"0\",\"yes\",\"no\",\"on\",\"off\"],\"default\":\"true\"}},{\"name\":\"replaced\",\"in\":\"query\",\"description\":\"Include replaced/modified content in the diff output.\\nWhen disabled, content that was modified (changed from one value to another) will not appear in the diff.\\nAccepts: true, false, 1, 0, yes, no, on, off\\n\",\"schema\":{\"type\":\"string\",\"enum\":[\"true\",\"false\",\"1\",\"0\",\"yes\",\"no\",\"on\",\"off\"],\"default\":\"true\"}}],\"responses\":{\"200\":{\"description\":\"Formatted diff between the two snapshots\",\"content\":{\"text/plain\":{\"schema\":{\"type\":\"string\",\"description\":\"Plain text diff with change markers\"}},\"text/html\":{\"schema\":{\"type\":\"string\",\"description\":\"HTML formatted diff with styling\"}}}},\"400\":{\"description\":\"Invalid format parameter or invalid request\"},\"404\":{\"description\":\"Watch not found, timestamps not found, or insufficient history\"}}}},\"/watch/{uuid}/favicon\":{\"get\":{\"operationId\":\"getWatchFavicon\",\"tags\":[\"Favicon\"],\"summary\":\"Get watch favicon\",\"description\":\"Get the favicon for a web page change monitor (watch) as displayed in the watch overview list.\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f/favicon\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  --output favicon.ico\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nuuid = '095be615-a8ad-4c33-8e9c-c7612fbf6c9f'\\nresponse = requests.get(f'http://localhost:5000/api/v1/watch/{uuid}/favicon', headers=headers)\\nwith open('favicon.ico', 'wb') as f:\\n    f.write(response.content)\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Web page change monitor (watch) unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"200\":{\"description\":\"Favicon binary data\",\"content\":{\"image/*\":{\"schema\":{\"type\":\"string\",\"format\":\"binary\"}}}},\"404\":{\"description\":\"Favicon not found\"}}}},\"/tags\":{\"get\":{\"operationId\":\"listTags\",\"tags\":[\"Group / Tag Management\"],\"summary\":\"List all tags\",\"description\":\"Return list of available tags/groups\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/tags\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nresponse = requests.get('http://localhost:5000/api/v1/tags', headers=headers)\\nprint(response.json())\\n\"}],\"responses\":{\"200\":{\"description\":\"List of tags\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/Tag\"}},\"example\":{\"550e8400-e29b-41d4-a716-446655440000\":{\"uuid\":\"550e8400-e29b-41d4-a716-446655440000\",\"title\":\"Production Sites\",\"notification_urls\":[\"mailto:admin@example.com\"],\"notification_muted\":false},\"330e8400-e29b-41d4-a716-446655440001\":{\"uuid\":\"330e8400-e29b-41d4-a716-446655440001\",\"title\":\"News Sources\",\"notification_urls\":[\"discord://webhook_id/webhook_token\"],\"notification_muted\":false}}}}}}}},\"/tag\":{\"post\":{\"operationId\":\"createTag\",\"tags\":[\"Group / Tag Management\"],\"summary\":\"Create tag\",\"description\":\"Create a single tag/group\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X POST \\\"http://localhost:5000/api/v1/tag\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"title\\\": \\\"Important Sites\\\"\\n  }'\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {\\n    'x-api-key': 'YOUR_API_KEY',\\n    'Content-Type': 'application/json'\\n}\\ndata = {'title': 'Important Sites'}\\nresponse = requests.post('http://localhost:5000/api/v1/tag',\\n                       headers=headers, json=data)\\nprint(response.json())\\n\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/CreateTag\"},\"example\":{\"title\":\"Important Sites\"}}}},\"responses\":{\"201\":{\"description\":\"Tag created successfully\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"uuid\":{\"type\":\"string\",\"format\":\"uuid\",\"description\":\"UUID of the created tag\"}}}}}},\"400\":{\"description\":\"Invalid or unsupported tag\"}}}},\"/tag/{uuid}\":{\"get\":{\"operationId\":\"getTag\",\"tags\":[\"Group / Tag Management\"],\"summary\":\"Get single tag\",\"description\":\"Retrieve tag information, set notification_muted status, recheck all web page change monitors (watches) in tag.\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\ntag_uuid = '550e8400-e29b-41d4-a716-446655440000'\\nresponse = requests.get(f'http://localhost:5000/api/v1/tag/{tag_uuid}', headers=headers)\\nprint(response.json())\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Tag unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}},{\"name\":\"muted\",\"in\":\"query\",\"description\":\"Set mute state\",\"schema\":{\"type\":\"string\",\"enum\":[\"muted\",\"unmuted\"]}},{\"name\":\"recheck\",\"in\":\"query\",\"description\":\"Queue all web page change monitors (watches) with this tag for recheck\",\"schema\":{\"type\":\"string\",\"enum\":[\"true\"]}}],\"responses\":{\"200\":{\"description\":\"Tag information or operation result\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Tag\"}},\"text/plain\":{\"schema\":{\"type\":\"string\",\"example\":\"OK\"}}}},\"404\":{\"description\":\"Tag not found\"}}},\"put\":{\"operationId\":\"updateTag\",\"tags\":[\"Group / Tag Management\"],\"summary\":\"Update tag\",\"description\":\"Update an existing tag using JSON\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X PUT \\\"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"title\\\": \\\"Updated Production Sites\\\",\\n    \\\"notification_muted\\\": false\\n  }'\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {\\n    'x-api-key': 'YOUR_API_KEY',\\n    'Content-Type': 'application/json'\\n}\\ntag_uuid = '550e8400-e29b-41d4-a716-446655440000'\\ndata = {\\n    'title': 'Updated Production Sites',\\n    'notification_muted': False\\n}\\nresponse = requests.put(f'http://localhost:5000/api/v1/tag/{tag_uuid}', \\n                      headers=headers, json=data)\\nprint(response.text)\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Tag unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Tag\"}}}},\"responses\":{\"200\":{\"description\":\"Tag updated successfully\"},\"500\":{\"description\":\"Server error\"}}},\"delete\":{\"operationId\":\"deleteTag\",\"tags\":[\"Group / Tag Management\"],\"summary\":\"Delete tag\",\"description\":\"Delete a tag/group and remove it from all web page change monitors (watches)\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X DELETE \\\"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\ntag_uuid = '550e8400-e29b-41d4-a716-446655440000'\\nresponse = requests.delete(f'http://localhost:5000/api/v1/tag/{tag_uuid}', headers=headers)\\nprint(response.text)\\n\"}],\"parameters\":[{\"name\":\"uuid\",\"in\":\"path\",\"required\":true,\"description\":\"Tag unique ID\",\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"200\":{\"description\":\"Tag deleted successfully\"}}}},\"/notifications\":{\"get\":{\"operationId\":\"getNotifications\",\"tags\":[\"Notifications\"],\"summary\":\"Get notification URLs\",\"description\":\"Return the notification URL list from the configuration\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/notifications\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nresponse = requests.get('http://localhost:5000/api/v1/notifications', headers=headers)\\nprint(response.json())\\n\"}],\"responses\":{\"200\":{\"description\":\"List of notification URLs\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/NotificationUrls\"}}}}}},\"post\":{\"operationId\":\"addNotifications\",\"tags\":[\"Notifications\"],\"summary\":\"Add notification URLs\",\"description\":\"Add one or more notification URLs to the configuration\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X POST \\\"http://localhost:5000/api/v1/notifications\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"notification_urls\\\": [\\n      \\\"mailto:admin@example.com\\\",\\n      \\\"discord://webhook_id/webhook_token\\\"\\n    ]\\n  }'\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {\\n    'x-api-key': 'YOUR_API_KEY',\\n    'Content-Type': 'application/json'\\n}\\ndata = {\\n    'notification_urls': [\\n        'mailto:admin@example.com',\\n        'discord://webhook_id/webhook_token'\\n    ]\\n}\\nresponse = requests.post('http://localhost:5000/api/v1/notifications', \\n                       headers=headers, json=data)\\nprint(response.json())\\n\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/NotificationUrls\"},\"example\":{\"notification_urls\":[\"mailto:admin@example.com\",\"discord://webhook_id/webhook_token\"]}}}},\"responses\":{\"201\":{\"description\":\"Notification URLs added successfully\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/NotificationUrls\"}}}},\"400\":{\"description\":\"Invalid input\"}}},\"put\":{\"operationId\":\"replaceNotifications\",\"tags\":[\"Notifications\"],\"summary\":\"Replace notification URLs\",\"description\":\"Replace all notification URLs with the provided list (can be empty)\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X PUT \\\"http://localhost:5000/api/v1/notifications\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"notification_urls\\\": [\\n      \\\"mailto:newadmin@example.com\\\"\\n    ]\\n  }'\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {\\n    'x-api-key': 'YOUR_API_KEY',\\n    'Content-Type': 'application/json'\\n}\\ndata = {\\n    'notification_urls': [\\n        'mailto:newadmin@example.com'\\n    ]\\n}\\nresponse = requests.put('http://localhost:5000/api/v1/notifications', \\n                      headers=headers, json=data)\\nprint(response.json())\\n\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/NotificationUrls\"}}}},\"responses\":{\"200\":{\"description\":\"Notification URLs replaced successfully\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/NotificationUrls\"}}}},\"400\":{\"description\":\"Invalid input\"}}},\"delete\":{\"operationId\":\"deleteNotifications\",\"tags\":[\"Notifications\"],\"summary\":\"Delete notification URLs\",\"description\":\"Delete one or more notification URLs from the configuration\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X DELETE \\\"http://localhost:5000/api/v1/notifications\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: application/json\\\" \\\\\\n  -d '{\\n    \\\"notification_urls\\\": [\\n      \\\"mailto:admin@example.com\\\"\\n    ]\\n  }'\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {\\n    'x-api-key': 'YOUR_API_KEY',\\n    'Content-Type': 'application/json'\\n}\\ndata = {\\n    'notification_urls': [\\n        'mailto:admin@example.com'\\n    ]\\n}\\nresponse = requests.delete('http://localhost:5000/api/v1/notifications', \\n                         headers=headers, json=data)\\nprint(response.status_code)\\n\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/NotificationUrls\"}}}},\"responses\":{\"204\":{\"description\":\"Notification URLs deleted successfully\"},\"400\":{\"description\":\"No matching notification URLs found\"}}}},\"/search\":{\"get\":{\"operationId\":\"searchWatches\",\"tags\":[\"Search\"],\"summary\":\"Search watches\",\"description\":\"Search web page change monitors (watches) by URL or title text\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/search?q=example.com\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nparams = {'q': 'example.com'}\\nresponse = requests.get('http://localhost:5000/api/v1/search', \\n                      headers=headers, params=params)\\nprint(response.json())\\n\"}],\"parameters\":[{\"name\":\"q\",\"in\":\"query\",\"required\":true,\"description\":\"Search query to match against watch URLs and titles\",\"schema\":{\"type\":\"string\"}},{\"name\":\"tag\",\"in\":\"query\",\"description\":\"Tag name to limit results (name not UUID)\",\"schema\":{\"type\":\"string\"}},{\"name\":\"partial\",\"in\":\"query\",\"description\":\"Allow partial matching of URL query\",\"schema\":{\"type\":\"string\"}}],\"responses\":{\"200\":{\"description\":\"Search results\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SearchResult\"},\"example\":{\"watches\":{\"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\":{\"uuid\":\"095be615-a8ad-4c33-8e9c-c7612fbf6c9f\",\"url\":\"http://example.com\",\"title\":\"Example Website Monitor\",\"tags\":[\"550e8400-e29b-41d4-a716-446655440000\"],\"paused\":false,\"notification_muted\":false}}}}}}}}},\"/import\":{\"post\":{\"operationId\":\"importWatches\",\"tags\":[\"Import\"],\"summary\":\"Import watch URLs with configuration\",\"description\":\"Import a list of URLs to monitor with optional watch configuration. Accepts line-separated URLs in request body.\\n\\n**Configuration via Query Parameters:**\\n\\nYou can pass ANY watch configuration field as query parameters to apply settings to all imported watches.\\nAll parameters from the Watch schema are supported (processor, fetch_backend, notification_urls, etc.).\\n\\n**Special Parameters:**\\n- `tag` / `tag_uuids` - Assign tags to imported watches\\n- `proxy` - Use specific proxy for imported watches\\n- `dedupe` - Skip duplicate URLs (default: true)\\n\\n**Type Conversion:**\\n- Booleans: `true`, `false`, `1`, `0`, `yes`, `no`\\n- Arrays: Comma-separated or JSON format (`[item1,item2]`)\\n- Objects: JSON format (`{\\\"key\\\":\\\"value\\\"}`)\\n- Numbers: Parsed as int or float\\n\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"# Basic import\\ncurl -X POST \\\"http://localhost:5000/api/v1/import\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: text/plain\\\" \\\\\\n  -d $'https://example.com\\\\nhttps://example.org\\\\nhttps://example.net'\\n\\n# Import with processor and fetch backend\\ncurl -X POST \\\"http://localhost:5000/api/v1/import?processor=restock_diff&fetch_backend=html_webdriver\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: text/plain\\\" \\\\\\n  -d $'https://example.com\\\\nhttps://example.org'\\n\\n# Import with multiple settings\\ncurl -X POST \\\"http://localhost:5000/api/v1/import?processor=restock_diff&paused=true&tag=production\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\" \\\\\\n  -H \\\"Content-Type: text/plain\\\" \\\\\\n  -d $'https://example.com'\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {\\n    'x-api-key': 'YOUR_API_KEY',\\n    'Content-Type': 'text/plain'\\n}\\n\\n# Basic import\\nurls = 'https://example.com\\\\nhttps://example.org\\\\nhttps://example.net'\\nresponse = requests.post('http://localhost:5000/api/v1/import',\\n                       headers=headers, data=urls)\\nprint(response.json())\\n\\n# Import with configuration\\nparams = {\\n    'processor': 'restock_diff',\\n    'fetch_backend': 'html_webdriver',\\n    'paused': 'false',\\n    'tag': 'production'\\n}\\nresponse = requests.post('http://localhost:5000/api/v1/import',\\n                       headers=headers, params=params, data=urls)\\nprint(response.json())\\n\"}],\"parameters\":[{\"name\":\"tag_uuids\",\"in\":\"query\",\"description\":\"Tag UUID(s) to apply to imported watches (comma-separated for multiple)\",\"schema\":{\"type\":\"string\"},\"example\":\"550e8400-e29b-41d4-a716-446655440000\"},{\"name\":\"tag\",\"in\":\"query\",\"description\":\"Tag name to apply to imported watches\",\"schema\":{\"type\":\"string\"},\"example\":\"production\"},{\"name\":\"proxy\",\"in\":\"query\",\"description\":\"Proxy key to use for imported watches\",\"schema\":{\"type\":\"string\"},\"example\":\"proxy1\"},{\"name\":\"dedupe\",\"in\":\"query\",\"description\":\"Skip duplicate URLs (default true)\",\"schema\":{\"type\":\"boolean\",\"default\":true}}],\"requestBody\":{\"required\":true,\"content\":{\"text/plain\":{\"schema\":{\"type\":\"string\"},\"example\":\"https://example.com\\nhttps://example.org\\nhttps://example.net\\n\"}}},\"responses\":{\"200\":{\"description\":\"URLs imported successfully\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"},\"description\":\"List of created watch UUIDs\"}}}},\"500\":{\"description\":\"Server error\"}}}},\"/systeminfo\":{\"get\":{\"operationId\":\"getSystemInfo\",\"tags\":[\"System Information\"],\"summary\":\"Get system information\",\"description\":\"Return information about the current system state\",\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"curl -X GET \\\"http://localhost:5000/api/v1/systeminfo\\\" \\\\\\n  -H \\\"x-api-key: YOUR_API_KEY\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\nheaders = {'x-api-key': 'YOUR_API_KEY'}\\nresponse = requests.get('http://localhost:5000/api/v1/systeminfo', headers=headers)\\nprint(response.json())\\n\"}],\"responses\":{\"200\":{\"description\":\"System information\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SystemInfo\"},\"example\":{\"watch_count\":42,\"tag_count\":5,\"uptime\":\"2 days, 3:45:12\",\"version\":\"0.50.10\"}}}}}}},\"/full-spec\":{\"get\":{\"operationId\":\"getFullApiSpec\",\"tags\":[\"Plugin API Extensions\"],\"summary\":\"Get full live API spec\",\"description\":\"Return the fully merged OpenAPI specification for this instance.\\n\\nUnlike the static `api-spec.yaml` shipped with the application, this endpoint returns the\\nspec dynamically merged with any `api.yaml` schemas provided by installed processor plugins.\\n\\n**Use this URL** with Swagger UI or Redoc to get schema-accurate documentation for your\\nspecific install — it includes every `processor_config_<name>` schema block contributed by\\ninstalled processors (e.g. `processor_config_restock_diff` from the built-in restock plugin).\\n\\nThis endpoint requires no authentication and returns YAML.\\n\\nTo load it directly in Swagger UI, paste the URL into the \\\"Explore\\\" box:\\n```\\nhttp://localhost:5000/api/v1/full-spec\\n```\\n\",\"security\":[],\"x-code-samples\":[{\"lang\":\"curl\",\"source\":\"# Fetch the live merged spec (no API key needed)\\ncurl -X GET \\\"http://localhost:5000/api/v1/full-spec\\\"\\n\"},{\"lang\":\"Python\",\"source\":\"import requests\\n\\n# No authentication required\\nresponse = requests.get('http://localhost:5000/api/v1/full-spec')\\nprint(response.text)  # Returns YAML\\n\"}],\"responses\":{\"200\":{\"description\":\"Merged OpenAPI specification in YAML format. Includes all processor plugin schemas\\n(e.g. `processor_config_restock_diff`) not present in the static `api-spec.yaml`.\\n\",\"content\":{\"application/yaml\":{\"schema\":{\"type\":\"string\"}}}}}}}}}},\"searchIndex\":{\"store\":[\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API\",\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Where-to-find-my-API-key\",\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Connection-URL\",\"section/ChangeDetection.io-Web-page-monitoring-and-notifications-API/Authentication\",\"tag/Watch-Management\",\"tag/Watch-Management/operation/listWatches\",\"tag/Watch-Management/operation/createWatch\",\"tag/Watch-Management/operation/getWatch\",\"tag/Watch-Management/operation/updateWatch\",\"tag/Watch-Management/operation/deleteWatch\",\"tag/Watch-History\",\"tag/Watch-History/operation/getWatchHistory\",\"tag/Watch-History/operation/getWatchHistoryDiff\",\"tag/Snapshots\",\"tag/Snapshots/operation/getWatchSnapshot\",\"tag/Favicon\",\"tag/Favicon/operation/getWatchFavicon\",\"tag/Group-Tag-Management\",\"tag/Group-Tag-Management/operation/listTags\",\"tag/Group-Tag-Management/operation/createTag\",\"tag/Group-Tag-Management/operation/getTag\",\"tag/Group-Tag-Management/operation/updateTag\",\"tag/Group-Tag-Management/operation/deleteTag\",\"tag/Notifications\",\"tag/Notifications/operation/getNotifications\",\"tag/Notifications/operation/addNotifications\",\"tag/Notifications/operation/replaceNotifications\",\"tag/Notifications/operation/deleteNotifications\",\"tag/Search\",\"tag/Search/operation/searchWatches\",\"tag/Import\",\"tag/Import/operation/importWatches\",\"tag/System-Information\",\"tag/System-Information/operation/getSystemInfo\",\"tag/Plugin-API-Extensions\",\"tag/Plugin-API-Extensions/How-Processor-Plugins-Extend-the-API\",\"tag/Plugin-API-Extensions/operation/getFullApiSpec\"],\"index\":{\"version\":\"2.3.9\",\"fields\":[\"title\",\"description\"],\"fieldVectors\":[[\"title/0\",[0,1.088,1,0.553,2,0.478,3,0.351,4,0.553,5,0.685]],[\"description/0\",[0,2.006,2,0.881,4,1.019,5,1.666,6,3.672,7,2.424,8,0.077,9,2.71,10,0.818,11,3.672,12,3.672,13,3.672,14,3.197,15,3.092,16,2.424,17,3.092,18,3.672,19,3.672,20,3.672,21,3.672,22,3.672,23,3.672]],[\"title/1\",[5,1.004,16,1.927,24,1.746]],[\"description/1\",[0,2.123,5,2.026,16,2.565,24,3.005,25,3.885,26,3.271,27,3.885,28,1.437,29,3.885,30,3.271,31,3.885,32,3.885,33,3.271,34,3.885,35,3.885,36,3.885]],[\"title/2\",[37,3.457,38,0.714]],[\"description/2\",[0,2.67,5,1.281,14,3.226,26,3.135,38,1.126,39,3.723,40,3.723,41,3.723,42,3.723,43,3.723,44,3.723,45,1.11,46,3.723,47,2.747,48,3.723,49,4.887,50,3.723,51,3.723]],[\"title/3\",[52,3.126]],[\"description/3\",[5,1.85,14,2.854,24,2.585,52,3.19,53,3.968,54,3.19,55,3.19,56,3.64,57,4.323,58,3.64,59,3.64]],[\"title/4\",[7,2.282,8,0.073]],[\"description/4\",[1,0.954,2,0.825,3,0.817,4,0.954,7,2.269,8,0.098,28,1.271,38,0.709,60,2.894,61,3.437,62,1.878,63,1.726,64,1.878,65,1.474,66,2.536,67,2.894,68,3.437,69,1.474,70,3.437,71,0.887,72,1.368,73,2.536,74,3.437,75,2.894,76,2.536,77,2.894,78,2.269]],[\"title/5\",[8,0.073,79,1.031]],[\"description/5\",[1,1.219,2,1.054,3,0.774,8,0.114,71,1.134,79,1.31,80,2.036,81,4.394,82,2.901,83,4.394,84,4.394]],[\"title/6\",[8,0.061,62,1.595,85,2.458]],[\"description/6\",[1,0.722,2,0.624,3,0.459,4,0.722,8,0.095,10,0.849,28,1.667,38,0.537,54,1.921,56,2.192,62,1.422,69,1.116,71,0.672,72,1.036,73,1.921,78,1.718,86,3.015,87,2.279,88,2.192,89,1.921,90,2.192,91,2.603,92,1.557,93,2.192,94,3.209,95,2.603,96,2.603,97,2.603,98,2.192,99,2.192,100,3.209,101,2.192,102,2.603,103,2.603,104,2.603,105,2.192,106,2.603,107,1.921,108,1.921,109,2.603,110,1.921]],[\"title/7\",[8,0.073,69,1.483]],[\"description/7\",[1,1.162,2,1.005,3,0.738,8,0.111,28,1.549,63,2.103,71,1.081,80,1.941,111,2.288,112,4.188,113,2.765,114,3.526,115,2.505,116,3.09]],[\"title/8\",[8,0.073,64,1.889]],[\"description/8\",[1,1.127,2,0.974,3,0.716,8,0.109,45,1.211,64,2.219,69,1.742,71,1.048,80,1.882,115,2.429,116,2.997,117,2.997,118,2.997,119,3.42,120,3.42,121,4.062]],[\"title/9\",[8,0.073,65,1.483]],[\"description/9\",[1,1.282,2,1.109,3,0.814,8,0.097,65,1.982,71,1.193,116,3.41,122,3.891,123,2.32]],[\"title/10\",[8,0.073,123,1.736]],[\"description/10\",[8,0.105,71,1.281,79,1.48,124,2.968,125,4.179]],[\"title/11\",[8,0.073,123,1.736]],[\"description/11\",[1,1.078,2,0.932,3,0.685,8,0.106,24,2.323,45,1.158,69,1.666,71,1.003,79,1.158,82,2.565,123,1.95,124,2.323,126,3.271,127,2.523,128,2.867,129,3.271,130,2.867,131,3.885]],[\"title/12\",[127,1.269,132,1.668,133,2.128,134,1.865]],[\"description/12\",[1,0.396,2,0.342,3,0.251,8,0.05,10,0.529,45,1.062,47,1.052,65,0.612,71,0.919,76,2.253,78,0.942,80,1.101,86,2.916,88,1.201,92,1.826,123,0.716,124,1.421,126,1.201,127,1.533,132,1.568,133,2.571,134,1.753,135,1.426,136,3.054,137,0.853,138,2.376,139,1.426,140,1.201,141,1.426,142,3.331,143,1.426,144,1.426,145,1.421,146,2.001,147,1.426,148,1.201,149,1.426,150,1.426,151,1.426,152,1.426,153,1.426,154,2.13,155,1.201,156,1.753,157,3.054,158,1.426,159,3.331,160,1.426,161,1.426,162,3.054,163,1.426,164,1.426,165,1.201,166,1.426,167,2.376,168,1.426,169,1.201,170,1.426,171,1.426,172,1.201,173,1.426,174,1.201,175,1.426,176,2.376,177,1.426,178,2.376,179,2.376,180,1.426,181,1.426,182,1.201,183,1.052,184,1.426,185,1.426,186,1.201,187,1.426,188,1.426,189,1.426,190,1.201,191,1.201,192,1.201,193,1.426,194,1.201,195,1.426]],[\"title/13\",[127,2.127]],[\"description/13\",[2,0.715,3,0.525,5,1.025,8,0.063,28,1.102,45,0.888,63,2.108,66,2.198,73,2.198,76,2.198,79,0.888,123,1.496,124,2.511,127,2.108,128,3.098,129,2.508,130,3.098,134,2.198,137,1.781,140,2.508,154,2.908,159,4.445,196,2.979,197,2.508,198,4.199,199,1.627,200,2.979,201,2.979,202,2.508,203,2.198]],[\"title/14\",[69,1.483,127,1.736]],[\"description/14\",[1,1.094,2,0.946,3,0.695,5,1.356,8,0.107,45,1.512,69,1.691,71,1.017,79,1.175,123,1.979,124,2.357,127,2.547,146,3.319,148,3.319,203,2.909,204,3.942]],[\"title/15\",[205,2.797]],[\"description/15\",[1,1.144,2,0.989,3,0.92,8,0.087,30,3.472,45,1.229,63,2.07,79,1.229,132,2.722,205,2.722,206,4.124,207,4.124,208,4.124,209,4.124,210,3.472]],[\"title/16\",[8,0.073,205,2.282]],[\"description/16\",[1,1.24,2,1.072,3,0.787,8,0.116,71,1.153,79,1.332,205,2.949,211,4.468,212,4.468,213,4.468]],[\"title/17\",[7,1.668,9,1.865,10,0.563,86,1.381]],[\"description/17\",[3,0.638,4,1.005,8,0.101,9,3.542,10,1.069,28,1.34,45,1.08,71,0.935,75,3.05,113,2.391,122,3.05,186,3.05,214,3.623,215,3.623,216,3.623,217,3.623,218,3.623,219,3.05,220,3.623,221,3.623,222,3.05,223,3.05]],[\"title/18\",[10,0.77,79,1.031]],[\"description/18\",[10,1.105,79,1.48,80,2.3,82,3.276,99,4.179]],[\"title/19\",[10,0.77,62,1.889]],[\"description/19\",[10,1.126,62,2.763,69,2.169,224,4.258]],[\"title/20\",[10,0.77,69,1.483]],[\"description/20\",[1,1.181,2,1.021,3,0.75,8,0.09,10,1.186,28,1.573,63,2.136,71,1.098,98,3.582,111,2.325,113,2.809,222,3.582,225,3.139]],[\"title/21\",[10,0.77,64,1.889]],[\"description/21\",[10,1.085,45,1.453,64,2.662,115,2.914,117,3.596,225,3.596]],[\"title/22\",[10,0.77,65,1.483]],[\"description/22\",[1,1.282,2,1.109,3,0.814,8,0.097,65,1.982,71,1.193,156,3.41,224,3.891,225,3.41]],[\"title/23\",[4,1.175]],[\"description/23\",[4,1.445,8,0.097,10,0.756,28,1.255,45,1.369,66,2.504,72,1.35,92,2.029,137,2.029,192,2.857,226,3.393,227,2.857,228,3.393,229,2.24,230,3.393,231,3.393,232,3.393,233,3.393,234,3.393,235,3.393,236,3.393,237,3.393,238,3.393,239,3.393,240,3.393,241,3.393]],[\"title/24\",[4,0.959,38,0.714]],[\"description/24\",[4,1.605,38,1.006,72,1.939,79,1.453,80,2.258]],[\"title/25\",[4,0.81,38,0.603,242,2.154]],[\"description/25\",[4,1.587,38,0.988,72,1.905,110,3.532,242,3.532,243,4.03]],[\"title/26\",[4,0.81,38,0.603,244,2.458]],[\"description/26\",[4,1.587,38,0.988,55,3.532,79,1.427,244,4.03,245,4.786]],[\"title/27\",[4,0.81,38,0.603,65,1.252]],[\"description/27\",[4,1.587,38,0.988,65,2.053,72,1.905,110,3.532,243,4.03]],[\"title/28\",[246,2.797]],[\"description/28\",[3,0.695,8,0.107,10,0.878,16,2.602,38,0.814,45,1.175,77,3.319,145,2.357,210,3.319,246,2.602,247,3.942,248,3.319,249,3.942,250,3.942,251,3.942,252,3.942,253,3.942,254,3.942]],[\"title/29\",[8,0.073,246,2.282]],[\"description/29\",[1,1.26,2,1.09,3,0.801,8,0.096,38,0.938,71,1.173,154,2.717,246,3.66,248,3.825]],[\"title/30\",[255,2.797]],[\"description/30\",[3,0.695,8,0.083,10,0.878,28,1.458,33,3.319,38,1.047,72,1.569,79,1.175,108,2.909,118,2.909,154,2.357,155,3.319,183,2.909,219,3.319,223,3.319,255,3.348,256,3.942]],[\"title/31\",[8,0.053,38,0.522,72,1.006,255,1.668]],[\"description/31\",[3,0.347,8,0.104,10,0.688,28,0.729,38,0.785,45,0.588,53,1.455,72,1.514,78,1.301,79,0.588,86,3.141,87,1.179,89,1.455,92,1.179,93,1.66,94,1.66,105,1.66,107,1.455,108,2.278,115,1.846,118,1.455,128,2.278,142,2.599,145,1.179,183,1.455,190,1.66,194,1.66,203,1.455,227,1.66,255,3.085,257,1.972,258,1.66,259,4.305,260,1.455,261,1.972,262,1.972,263,1.972,264,1.972,265,1.972,266,2.599,267,1.66,268,1.972,269,1.66,270,1.972,271,1.972,272,1.972,273,1.972,274,1.972,275,1.972,276,1.66,277,1.972,278,1.972,279,1.972,280,1.972,281,1.972]],[\"title/32\",[111,1.889,199,1.889]],[\"description/32\",[0,2.325,8,0.09,47,3.139,63,2.136,111,2.325,113,2.809,172,3.582,199,2.325,229,2.809,282,4.255,283,3.582,284,4.255,285,4.255,286,4.255]],[\"title/33\",[111,1.889,199,1.889]],[\"description/33\",[80,2.258,111,2.662,199,2.662,287,4.873,288,4.873,289,4.873]],[\"title/34\",[5,1.004,290,1.927,291,2.92]],[\"description/34\",[]],[\"title/35\",[5,0.869,87,1.511,290,1.668,292,2.128]],[\"description/35\",[0,0.761,3,0.411,5,0.184,8,0.054,14,0.353,15,0.45,17,1.962,24,0.319,28,0.198,38,0.36,45,0.612,53,0.733,58,1.729,59,1.729,60,0.45,62,0.761,63,0.268,64,0.953,65,0.426,67,0.45,71,0.601,72,0.554,82,0.92,85,0.45,86,3.435,87,2.007,89,2.216,90,1.729,92,0.594,100,0.45,101,0.45,107,0.394,115,0.833,117,0.394,119,0.45,120,0.45,125,0.45,130,0.394,132,0.353,137,0.319,145,0.594,154,0.319,156,0.394,165,0.45,169,0.45,174,0.45,182,0.45,197,0.837,199,0.292,202,0.837,229,1.151,242,0.733,258,0.45,260,1.719,266,1.962,267,1.468,269,0.837,276,0.45,290,0.92,292,0.45,293,0.534,294,1.468,295,0.534,296,1.962,297,0.534,298,0.534,299,0.534,300,0.534,301,0.837,302,0.534,303,0.534,304,0.994,305,0.534,306,0.534,307,0.534,308,0.534,309,0.534,310,0.733,311,0.994,312,0.994,313,0.837,314,0.837,315,0.837,316,0.534,317,0.994,318,0.534,319,0.534,320,0.534,321,0.534,322,0.994,323,0.534,324,0.837,325,0.534,326,0.994,327,0.534,328,0.534,329,0.534,330,1.962,331,0.534,332,0.534,333,1.744,334,0.45,335,0.534,336,0.994,337,1.393,338,0.534,339,0.994,340,0.534,341,0.994,342,1.393,343,0.994,344,0.534,345,0.534,346,0.534,347,2.054,348,0.994,349,2.801,350,1.393,351,1.393,352,1.393,353,0.534,354,0.534,355,0.534,356,0.534,357,0.45,358,0.45,359,0.534,360,2.054,361,0.534,362,0.534,363,0.534,364,0.534,365,0.534,366,0.534,367,0.45,368,1.744,369,1.744,370,2.33,371,0.534,372,0.994,373,0.534,374,0.534,375,1.744,376,1.393,377,1.393,378,0.534,379,1.393,380,0.534,381,0.534,382,1.393,383,0.534,384,0.534,385,0.534,386,0.534,387,1.744,388,0.994,389,0.994,390,0.534,391,0.534,392,0.534,393,1.393,394,0.534,395,0.994,396,0.534,397,0.534,398,0.534,399,0.534,400,0.534,401,0.534,402,0.534,403,0.534,404,0.534,405,0.45,406,0.45]],[\"title/36\",[5,0.869,114,2.128,294,2.128,310,1.865]],[\"description/36\",[38,0.749,45,0.727,52,1.798,54,1.798,55,1.798,80,2.01,86,2.37,87,2.171,137,2.171,145,2.171,191,2.052,229,1.609,260,2.679,283,2.052,290,2.396,296,2.052,301,3.653,310,1.798,313,3.057,314,3.057,315,2.052,324,2.052,330,2.052,334,2.052,357,2.052,358,2.052,367,2.052,405,2.052,406,2.052,407,2.437,408,3.63,409,2.437,410,2.437,411,2.437,412,2.437,413,2.437,414,2.437,415,2.437,416,2.437,417,2.437,418,2.437,419,2.437,420,2.437,421,2.437,422,2.437,423,2.437]]],\"invertedIndex\":[[\"\",{\"_index\":86,\"title\":{\"17\":{}},\"description\":{\"6\":{},\"12\":{},\"31\":{},\"35\":{},\"36\":{}}}],[\"0\",{\"_index\":272,\"title\":{},\"description\":{\"31\":{}}}],[\"1\",{\"_index\":271,\"title\":{},\"description\":{\"31\":{}}}],[\"10.00\",{\"_index\":402,\"title\":{},\"description\":{\"35\":{}}}],[\"2\",{\"_index\":201,\"title\":{},\"description\":{\"13\":{}}}],[\"5\",{\"_index\":389,\"title\":{},\"description\":{\"35\":{}}}],[\"500.00\",{\"_index\":403,\"title\":{},\"description\":{\"35\":{}}}],[\"5000\",{\"_index\":43,\"title\":{},\"description\":{\"2\":{}}}],[\"__init__.pi\",{\"_index\":323,\"title\":{},\"description\":{\"35\":{}}}],[\"abov\",{\"_index\":381,\"title\":{},\"description\":{\"35\":{}}}],[\"accept\",{\"_index\":118,\"title\":{},\"description\":{\"8\":{},\"30\":{},\"31\":{}}}],[\"accord\",{\"_index\":196,\"title\":{},\"description\":{\"13\":{}}}],[\"activ\",{\"_index\":366,\"title\":{},\"description\":{\"35\":{}}}],[\"ad\",{\"_index\":157,\"title\":{},\"description\":{\"12\":{}}}],[\"add\",{\"_index\":242,\"title\":{\"25\":{}},\"description\":{\"25\":{},\"35\":{}}}],[\"addit\",{\"_index\":165,\"title\":{},\"description\":{\"12\":{},\"35\":{}}}],[\"advanc\",{\"_index\":106,\"title\":{},\"description\":{\"6\":{}}}],[\"alert\",{\"_index\":370,\"title\":{},\"description\":{\"35\":{}}}],[\"all_chang\",{\"_index\":372,\"title\":{},\"description\":{\"35\":{}}}],[\"allow\",{\"_index\":186,\"title\":{},\"description\":{\"12\":{},\"17\":{}}}],[\"alongsid\",{\"_index\":322,\"title\":{},\"description\":{\"35\":{}}}],[\"alway\",{\"_index\":311,\"title\":{},\"description\":{\"35\":{}}}],[\"api\",{\"_index\":5,\"title\":{\"0\":{},\"1\":{},\"34\":{},\"35\":{},\"36\":{}},\"description\":{\"0\":{},\"1\":{},\"2\":{},\"3\":{},\"13\":{},\"14\":{},\"35\":{}}}],[\"api-spec.yaml\",{\"_index\":412,\"title\":{},\"description\":{\"36\":{}}}],[\"api.yaml\",{\"_index\":296,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"api/v1\",{\"_index\":39,\"title\":{},\"description\":{\"2\":{}}}],[\"api/v1/full-spec\",{\"_index\":312,\"title\":{},\"description\":{\"35\":{}}}],[\"appli\",{\"_index\":183,\"title\":{},\"description\":{\"12\":{},\"30\":{},\"31\":{}}}],[\"applic\",{\"_index\":191,\"title\":{},\"description\":{\"12\":{},\"36\":{}}}],[\"application/json\",{\"_index\":351,\"title\":{},\"description\":{\"35\":{}}}],[\"argument\",{\"_index\":129,\"title\":{},\"description\":{\"11\":{},\"13\":{}}}],[\"array\",{\"_index\":94,\"title\":{},\"description\":{\"6\":{},\"31\":{}}}],[\"assign\",{\"_index\":263,\"title\":{},\"description\":{\"31\":{}}}],[\"associ\",{\"_index\":207,\"title\":{},\"description\":{\"15\":{}}}],[\"authent\",{\"_index\":52,\"title\":{\"3\":{}},\"description\":{\"3\":{},\"36\":{}}}],[\"automat\",{\"_index\":33,\"title\":{},\"description\":{\"1\":{},\"30\":{}}}],[\"avail\",{\"_index\":82,\"title\":{},\"description\":{\"5\":{},\"11\":{},\"18\":{},\"35\":{}}}],[\"base\",{\"_index\":48,\"title\":{},\"description\":{\"2\":{}}}],[\"bash\",{\"_index\":387,\"title\":{},\"description\":{\"35\":{}}}],[\"basic\",{\"_index\":83,\"title\":{},\"description\":{\"5\":{}}}],[\"be\",{\"_index\":70,\"title\":{},\"description\":{\"4\":{}}}],[\"behaviour\",{\"_index\":340,\"title\":{},\"description\":{\"35\":{}}}],[\"below\",{\"_index\":15,\"title\":{},\"description\":{\"0\":{},\"35\":{}}}],[\"between\",{\"_index\":133,\"title\":{\"12\":{}},\"description\":{\"12\":{}}}],[\"block\",{\"_index\":367,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"bodi\",{\"_index\":107,\"title\":{},\"description\":{\"6\":{},\"31\":{},\"35\":{}}}],[\"boolean\",{\"_index\":269,\"title\":{},\"description\":{\"31\":{},\"35\":{}}}],[\"box\",{\"_index\":421,\"title\":{},\"description\":{\"36\":{}}}],[\"browser_step\",{\"_index\":109,\"title\":{},\"description\":{\"6\":{}}}],[\"built\",{\"_index\":12,\"title\":{},\"description\":{\"0\":{}}}],[\"built-in\",{\"_index\":357,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"bulk\",{\"_index\":219,\"title\":{},\"description\":{\"17\":{},\"30\":{}}}],[\"categor\",{\"_index\":216,\"title\":{},\"description\":{\"17\":{}}}],[\"cc0cfffa-f449-477b-83ea-0caafd1dc091\",{\"_index\":394,\"title\":{},\"description\":{\"35\":{}}}],[\"certain\",{\"_index\":253,\"title\":{},\"description\":{\"28\":{}}}],[\"chang\",{\"_index\":71,\"title\":{},\"description\":{\"4\":{},\"5\":{},\"6\":{},\"7\":{},\"8\":{},\"9\":{},\"10\":{},\"11\":{},\"12\":{},\"14\":{},\"16\":{},\"17\":{},\"20\":{},\"22\":{},\"29\":{},\"35\":{}}}],[\"changedetection.io\",{\"_index\":0,\"title\":{\"0\":{}},\"description\":{\"0\":{},\"1\":{},\"2\":{},\"32\":{},\"35\":{}}}],[\"changedetectionio/processors/<nam\",{\"_index\":295,\"title\":{},\"description\":{\"35\":{}}}],[\"changedetectionio/processors/my_processor/api.yaml\",{\"_index\":325,\"title\":{},\"description\":{\"35\":{}}}],[\"check\",{\"_index\":73,\"title\":{},\"description\":{\"4\":{},\"6\":{},\"13\":{}}}],[\"chosen\",{\"_index\":141,\"title\":{},\"description\":{\"12\":{}}}],[\"click\",{\"_index\":32,\"title\":{},\"description\":{\"1\":{}}}],[\"clipboard\",{\"_index\":35,\"title\":{},\"description\":{\"1\":{}}}],[\"code\",{\"_index\":302,\"title\":{},\"description\":{\"35\":{}}}],[\"collect\",{\"_index\":251,\"title\":{},\"description\":{\"28\":{}}}],[\"color\",{\"_index\":162,\"title\":{},\"description\":{\"12\":{}}}],[\"coloris\",{\"_index\":189,\"title\":{},\"description\":{\"12\":{}}}],[\"comma-separ\",{\"_index\":274,\"title\":{},\"description\":{\"31\":{}}}],[\"command\",{\"_index\":18,\"title\":{},\"description\":{\"0\":{}}}],[\"compar\",{\"_index\":138,\"title\":{},\"description\":{\"12\":{}}}],[\"comparison\",{\"_index\":136,\"title\":{},\"description\":{\"12\":{}}}],[\"complet\",{\"_index\":317,\"title\":{},\"description\":{\"35\":{}}}],[\"compon\",{\"_index\":335,\"title\":{},\"description\":{\"35\":{}}}],[\"concis\",{\"_index\":81,\"title\":{},\"description\":{\"5\":{}}}],[\"configur\",{\"_index\":72,\"title\":{\"31\":{}},\"description\":{\"4\":{},\"6\":{},\"23\":{},\"24\":{},\"25\":{},\"27\":{},\"30\":{},\"31\":{},\"35\":{}}}],[\"connect\",{\"_index\":37,\"title\":{\"2\":{}},\"description\":{}}],[\"content\",{\"_index\":76,\"title\":{},\"description\":{\"4\":{},\"12\":{},\"13\":{}}}],[\"content-typ\",{\"_index\":350,\"title\":{},\"description\":{\"35\":{}}}],[\"contribut\",{\"_index\":416,\"title\":{},\"description\":{\"36\":{}}}],[\"convent\",{\"_index\":328,\"title\":{},\"description\":{\"35\":{}}}],[\"convers\",{\"_index\":268,\"title\":{},\"description\":{\"31\":{}}}],[\"copi\",{\"_index\":34,\"title\":{},\"description\":{\"1\":{}}}],[\"core\",{\"_index\":60,\"title\":{},\"description\":{\"4\":{},\"35\":{}}}],[\"count\",{\"_index\":285,\"title\":{},\"description\":{\"32\":{}}}],[\"creat\",{\"_index\":62,\"title\":{\"6\":{},\"19\":{}},\"description\":{\"4\":{},\"6\":{},\"19\":{},\"35\":{}}}],[\"criteria\",{\"_index\":254,\"title\":{},\"description\":{\"28\":{}}}],[\"curl\",{\"_index\":17,\"title\":{},\"description\":{\"0\":{},\"35\":{}}}],[\"current\",{\"_index\":287,\"title\":{},\"description\":{\"33\":{}}}],[\"custom\",{\"_index\":188,\"title\":{},\"description\":{\"12\":{}}}],[\"d\",{\"_index\":352,\"title\":{},\"description\":{\"35\":{}}}],[\"dashboard\",{\"_index\":30,\"title\":{},\"description\":{\"1\":{},\"15\":{}}}],[\"data\",{\"_index\":361,\"title\":{},\"description\":{\"35\":{}}}],[\"dedup\",{\"_index\":264,\"title\":{},\"description\":{\"31\":{}}}],[\"deep-merg\",{\"_index\":305,\"title\":{},\"description\":{\"35\":{}}}],[\"default\",{\"_index\":92,\"title\":{},\"description\":{\"6\":{},\"12\":{},\"23\":{},\"31\":{},\"35\":{}}}],[\"defin\",{\"_index\":304,\"title\":{},\"description\":{\"35\":{}}}],[\"delet\",{\"_index\":65,\"title\":{\"9\":{},\"22\":{},\"27\":{}},\"description\":{\"4\":{},\"9\":{},\"12\":{},\"22\":{},\"27\":{},\"35\":{}}}],[\"descript\",{\"_index\":337,\"title\":{},\"description\":{\"35\":{}}}],[\"detail\",{\"_index\":172,\"title\":{},\"description\":{\"12\":{},\"32\":{}}}],[\"detect\",{\"_index\":125,\"title\":{},\"description\":{\"10\":{},\"35\":{}}}],[\"dif\",{\"_index\":168,\"title\":{},\"description\":{\"12\":{}}}],[\"diff\",{\"_index\":179,\"title\":{},\"description\":{\"12\":{}}}],[\"differ\",{\"_index\":132,\"title\":{\"12\":{}},\"description\":{\"12\":{},\"15\":{},\"35\":{}}}],[\"directli\",{\"_index\":418,\"title\":{},\"description\":{\"36\":{}}}],[\"directori\",{\"_index\":321,\"title\":{},\"description\":{\"35\":{}}}],[\"disabl\",{\"_index\":174,\"title\":{},\"description\":{\"12\":{},\"35\":{}}}],[\"discord\",{\"_index\":231,\"title\":{},\"description\":{\"23\":{}}}],[\"display\",{\"_index\":211,\"title\":{},\"description\":{\"16\":{}}}],[\"document\",{\"_index\":405,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"driven\",{\"_index\":11,\"title\":{},\"description\":{\"0\":{}}}],[\"drop\",{\"_index\":378,\"title\":{},\"description\":{\"35\":{}}}],[\"duplic\",{\"_index\":265,\"title\":{},\"description\":{\"31\":{}}}],[\"dynam\",{\"_index\":413,\"title\":{},\"description\":{\"36\":{}}}],[\"e.g\",{\"_index\":324,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"each\",{\"_index\":67,\"title\":{},\"description\":{\"4\":{},\"35\":{}}}],[\"easili\",{\"_index\":25,\"title\":{},\"description\":{\"1\":{}}}],[\"email\",{\"_index\":230,\"title\":{},\"description\":{\"23\":{}}}],[\"empti\",{\"_index\":245,\"title\":{},\"description\":{\"26\":{}}}],[\"enabl\",{\"_index\":169,\"title\":{},\"description\":{\"12\":{},\"35\":{}}}],[\"endpoint\",{\"_index\":137,\"title\":{},\"description\":{\"12\":{},\"13\":{},\"23\":{},\"35\":{},\"36\":{}}}],[\"etc\",{\"_index\":194,\"title\":{},\"description\":{\"12\":{},\"31\":{}}}],[\"exampl\",{\"_index\":14,\"title\":{},\"description\":{\"0\":{},\"2\":{},\"3\":{},\"35\":{}}}],[\"exist\",{\"_index\":117,\"title\":{},\"description\":{\"8\":{},\"21\":{},\"35\":{}}}],[\"explor\",{\"_index\":420,\"title\":{},\"description\":{\"36\":{}}}],[\"extend\",{\"_index\":292,\"title\":{\"35\":{}},\"description\":{\"35\":{}}}],[\"extens\",{\"_index\":291,\"title\":{\"34\":{}},\"description\":{}}],[\"fals\",{\"_index\":270,\"title\":{},\"description\":{\"31\":{}}}],[\"false/off\",{\"_index\":177,\"title\":{},\"description\":{\"12\":{}}}],[\"faster\",{\"_index\":23,\"title\":{},\"description\":{\"0\":{}}}],[\"favicon\",{\"_index\":205,\"title\":{\"15\":{},\"16\":{}},\"description\":{\"15\":{},\"16\":{}}}],[\"fetch\",{\"_index\":130,\"title\":{},\"description\":{\"11\":{},\"13\":{},\"35\":{}}}],[\"fetch_backend\",{\"_index\":105,\"title\":{},\"description\":{\"6\":{},\"31\":{}}}],[\"field\",{\"_index\":89,\"title\":{},\"description\":{\"6\":{},\"31\":{},\"35\":{}}}],[\"file\",{\"_index\":202,\"title\":{},\"description\":{\"13\":{},\"35\":{}}}],[\"filter\",{\"_index\":77,\"title\":{},\"description\":{\"4\":{},\"28\":{}}}],[\"find\",{\"_index\":16,\"title\":{\"1\":{}},\"description\":{\"0\":{},\"1\":{},\"28\":{}}}],[\"float\",{\"_index\":281,\"title\":{},\"description\":{\"31\":{}}}],[\"follow\",{\"_index\":327,\"title\":{},\"description\":{\"35\":{}}}],[\"follow_price_chang\",{\"_index\":375,\"title\":{},\"description\":{\"35\":{}}}],[\"format\",{\"_index\":142,\"title\":{},\"description\":{\"12\":{},\"31\":{}}}],[\"found\",{\"_index\":26,\"title\":{},\"description\":{\"1\":{},\"2\":{}}}],[\"from_timestamp\",{\"_index\":152,\"title\":{},\"description\":{\"12\":{}}}],[\"full\",{\"_index\":114,\"title\":{\"36\":{}},\"description\":{\"7\":{}}}],[\"full-spec\",{\"_index\":423,\"title\":{},\"description\":{\"36\":{}}}],[\"fulli\",{\"_index\":407,\"title\":{},\"description\":{\"36\":{}}}],[\"fully-merg\",{\"_index\":309,\"title\":{},\"description\":{\"35\":{}}}],[\"function\",{\"_index\":61,\"title\":{},\"description\":{\"4\":{}}}],[\"gener\",{\"_index\":135,\"title\":{},\"description\":{\"12\":{}}}],[\"global\",{\"_index\":226,\"title\":{},\"description\":{\"23\":{}}}],[\"granular\",{\"_index\":170,\"title\":{},\"description\":{\"12\":{}}}],[\"green\",{\"_index\":164,\"title\":{},\"description\":{\"12\":{}}}],[\"group\",{\"_index\":9,\"title\":{\"17\":{}},\"description\":{\"0\":{},\"17\":{}}}],[\"group-wid\",{\"_index\":217,\"title\":{},\"description\":{\"17\":{}}}],[\"h\",{\"_index\":349,\"title\":{},\"description\":{\"35\":{}}}],[\"handl\",{\"_index\":293,\"title\":{},\"description\":{\"35\":{}}}],[\"header\",{\"_index\":56,\"title\":{},\"description\":{\"3\":{},\"6\":{}}}],[\"help\",{\"_index\":21,\"title\":{},\"description\":{\"0\":{}}}],[\"heurist\",{\"_index\":365,\"title\":{},\"description\":{\"35\":{}}}],[\"highlight\",{\"_index\":163,\"title\":{},\"description\":{\"12\":{}}}],[\"histor\",{\"_index\":126,\"title\":{},\"description\":{\"11\":{},\"12\":{}}}],[\"histori\",{\"_index\":123,\"title\":{\"10\":{},\"11\":{}},\"description\":{\"9\":{},\"11\":{},\"12\":{},\"13\":{},\"14\":{}}}],[\"hosted/subscript\",{\"_index\":46,\"title\":{},\"description\":{\"2\":{}}}],[\"html\",{\"_index\":159,\"title\":{},\"description\":{\"12\":{},\"13\":{}}}],[\"htmlcolor\",{\"_index\":160,\"title\":{},\"description\":{\"12\":{}}}],[\"http\",{\"_index\":57,\"title\":{},\"description\":{\"3\":{}}}],[\"http://localhost:5000/api/v1/full-spec\",{\"_index\":422,\"title\":{},\"description\":{\"36\":{}}}],[\"http://localhost:5000/api/v1/watch\",{\"_index\":348,\"title\":{},\"description\":{\"35\":{}}}],[\"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091\",{\"_index\":393,\"title\":{},\"description\":{\"35\":{}}}],[\"http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/histori\",{\"_index\":44,\"title\":{},\"description\":{\"2\":{}}}],[\"https://<your\",{\"_index\":50,\"title\":{},\"description\":{\"2\":{}}}],[\"https://example.com\",{\"_index\":353,\"title\":{},\"description\":{\"35\":{}}}],[\"https://example.com/product/widget\",{\"_index\":388,\"title\":{},\"description\":{\"35\":{}}}],[\"https://github.com/caronc/apprise](https://github.com/caronc/appris\",{\"_index\":241,\"title\":{},\"description\":{\"23\":{}}}],[\"hypothet\",{\"_index\":332,\"title\":{},\"description\":{\"35\":{}}}],[\"identifi\",{\"_index\":210,\"title\":{},\"description\":{\"15\":{},\"28\":{}}}],[\"imag\",{\"_index\":206,\"title\":{},\"description\":{\"15\":{}}}],[\"implement\",{\"_index\":187,\"title\":{},\"description\":{\"12\":{}}}],[\"import\",{\"_index\":255,\"title\":{\"30\":{},\"31\":{}},\"description\":{\"30\":{},\"31\":{}}}],[\"in_stock_onli\",{\"_index\":369,\"title\":{},\"description\":{\"35\":{}}}],[\"in_stock_process\",{\"_index\":368,\"title\":{},\"description\":{\"35\":{}}}],[\"includ\",{\"_index\":229,\"title\":{},\"description\":{\"23\":{},\"32\":{},\"35\":{},\"36\":{}}}],[\"individu\",{\"_index\":66,\"title\":{},\"description\":{\"4\":{},\"13\":{},\"23\":{}}}],[\"info\",{\"_index\":84,\"title\":{},\"description\":{\"5\":{}}}],[\"inform\",{\"_index\":111,\"title\":{\"32\":{},\"33\":{}},\"description\":{\"7\":{},\"20\":{},\"32\":{},\"33\":{}}}],[\"information](#operation/getwatch\",{\"_index\":121,\"title\":{},\"description\":{\"8\":{}}}],[\"inject\",{\"_index\":355,\"title\":{},\"description\":{\"35\":{}}}],[\"inlin\",{\"_index\":173,\"title\":{},\"description\":{\"12\":{}}}],[\"instal\",{\"_index\":301,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"instanc\",{\"_index\":283,\"title\":{},\"description\":{\"32\":{},\"36\":{}}}],[\"int\",{\"_index\":280,\"title\":{},\"description\":{\"31\":{}}}],[\"interfac\",{\"_index\":208,\"title\":{},\"description\":{\"15\":{}}}],[\"interv\",{\"_index\":74,\"title\":{},\"description\":{\"4\":{}}}],[\"item1,item2\",{\"_index\":275,\"title\":{},\"description\":{\"31\":{}}}],[\"json\",{\"_index\":115,\"title\":{},\"description\":{\"7\":{},\"8\":{},\"21\":{},\"31\":{},\"35\":{}}}],[\"json-ld\",{\"_index\":362,\"title\":{},\"description\":{\"35\":{}}}],[\"keep\",{\"_index\":200,\"title\":{},\"description\":{\"13\":{}}}],[\"key\",{\"_index\":24,\"title\":{\"1\":{}},\"description\":{\"1\":{},\"3\":{},\"11\":{},\"35\":{}}}],[\"key\\\":\\\"valu\",{\"_index\":277,\"title\":{},\"description\":{\"31\":{}}}],[\"key](./where-to-get-api-key.jpeg\",{\"_index\":36,\"title\":{},\"description\":{\"1\":{}}}],[\"keyword\",{\"_index\":147,\"title\":{},\"description\":{\"12\":{}}}],[\"known\",{\"_index\":215,\"title\":{},\"description\":{\"17\":{}}}],[\"label\",{\"_index\":345,\"title\":{},\"description\":{\"35\":{}}}],[\"lang\",{\"_index\":344,\"title\":{},\"description\":{\"35\":{}}}],[\"larg\",{\"_index\":250,\"title\":{},\"description\":{\"28\":{}}}],[\"last\",{\"_index\":198,\"title\":{},\"description\":{\"13\":{}}}],[\"latest\",{\"_index\":148,\"title\":{},\"description\":{\"12\":{},\"14\":{}}}],[\"left\",{\"_index\":399,\"title\":{},\"description\":{\"35\":{}}}],[\"level\",{\"_index\":239,\"title\":{},\"description\":{\"23\":{}}}],[\"line\",{\"_index\":19,\"title\":{},\"description\":{\"0\":{}}}],[\"line-level\",{\"_index\":176,\"title\":{},\"description\":{\"12\":{}}}],[\"line-separ\",{\"_index\":257,\"title\":{},\"description\":{\"31\":{}}}],[\"list\",{\"_index\":79,\"title\":{\"5\":{},\"18\":{}},\"description\":{\"5\":{},\"10\":{},\"11\":{},\"13\":{},\"14\":{},\"15\":{},\"16\":{},\"18\":{},\"24\":{},\"26\":{},\"30\":{},\"31\":{}}}],[\"live\",{\"_index\":294,\"title\":{\"36\":{}},\"description\":{\"35\":{}}}],[\"load\",{\"_index\":406,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"local\",{\"_index\":41,\"title\":{},\"description\":{\"2\":{}}}],[\"login\",{\"_index\":49,\"title\":{},\"description\":{\"2\":{}}}],[\"make\",{\"_index\":306,\"title\":{},\"description\":{\"35\":{}}}],[\"manag\",{\"_index\":7,\"title\":{\"4\":{},\"17\":{}},\"description\":{\"0\":{},\"4\":{}}}],[\"mani\",{\"_index\":234,\"title\":{},\"description\":{\"23\":{}}}],[\"mass\",{\"_index\":221,\"title\":{},\"description\":{\"17\":{}}}],[\"match\",{\"_index\":252,\"title\":{},\"description\":{\"28\":{}}}],[\"merg\",{\"_index\":408,\"title\":{},\"description\":{\"36\":{}}}],[\"method\",{\"_index\":104,\"title\":{},\"description\":{\"6\":{}}}],[\"microdata\",{\"_index\":364,\"title\":{},\"description\":{\"35\":{}}}],[\"minim\",{\"_index\":331,\"title\":{},\"description\":{\"35\":{}}}],[\"minimum\",{\"_index\":383,\"title\":{},\"description\":{\"35\":{}}}],[\"mode\",{\"_index\":88,\"title\":{},\"description\":{\"6\":{},\"12\":{}}}],[\"monitor\",{\"_index\":3,\"title\":{\"0\":{}},\"description\":{\"4\":{},\"5\":{},\"6\":{},\"7\":{},\"8\":{},\"9\":{},\"11\":{},\"12\":{},\"13\":{},\"14\":{},\"15\":{},\"16\":{},\"17\":{},\"20\":{},\"22\":{},\"28\":{},\"29\":{},\"30\":{},\"31\":{},\"35\":{}}}],[\"more\",{\"_index\":110,\"title\":{},\"description\":{\"6\":{},\"25\":{},\"27\":{}}}],[\"multipl\",{\"_index\":223,\"title\":{},\"description\":{\"17\":{},\"30\":{}}}],[\"muted/paus\",{\"_index\":112,\"title\":{},\"description\":{\"7\":{}}}],[\"my_processor\",{\"_index\":333,\"title\":{},\"description\":{\"35\":{}}}],[\"name\",{\"_index\":326,\"title\":{},\"description\":{\"35\":{}}}],[\"new\",{\"_index\":85,\"title\":{\"6\":{}},\"description\":{\"35\":{}}}],[\"no_markup=tru\",{\"_index\":181,\"title\":{},\"description\":{\"12\":{}}}],[\"notif\",{\"_index\":4,\"title\":{\"0\":{},\"23\":{},\"24\":{},\"25\":{},\"26\":{},\"27\":{}},\"description\":{\"0\":{},\"4\":{},\"6\":{},\"17\":{},\"23\":{},\"24\":{},\"25\":{},\"26\":{},\"27\":{}}}],[\"notification_bodi\",{\"_index\":96,\"title\":{},\"description\":{\"6\":{}}}],[\"notification_format\",{\"_index\":97,\"title\":{},\"description\":{\"6\":{}}}],[\"notification_mut\",{\"_index\":98,\"title\":{},\"description\":{\"6\":{},\"20\":{}}}],[\"notification_titl\",{\"_index\":95,\"title\":{},\"description\":{\"6\":{}}}],[\"notification_url\",{\"_index\":93,\"title\":{},\"description\":{\"6\":{},\"31\":{}}}],[\"null\",{\"_index\":395,\"title\":{},\"description\":{\"35\":{}}}],[\"number\",{\"_index\":278,\"title\":{},\"description\":{\"31\":{}}}],[\"number\\\\|nul\",{\"_index\":377,\"title\":{},\"description\":{\"35\":{}}}],[\"object\",{\"_index\":276,\"title\":{},\"description\":{\"31\":{},\"35\":{}}}],[\"omit\",{\"_index\":398,\"title\":{},\"description\":{\"35\":{}}}],[\"on\",{\"_index\":243,\"title\":{},\"description\":{\"25\":{},\"27\":{}}}],[\"openapi\",{\"_index\":409,\"title\":{},\"description\":{\"36\":{}}}],[\"opening/clos\",{\"_index\":185,\"title\":{},\"description\":{\"12\":{}}}],[\"oper\",{\"_index\":220,\"title\":{},\"description\":{\"17\":{}}}],[\"option\",{\"_index\":78,\"title\":{},\"description\":{\"4\":{},\"6\":{},\"12\":{},\"31\":{}}}],[\"organ\",{\"_index\":214,\"title\":{},\"description\":{\"17\":{}}}],[\"origin\",{\"_index\":384,\"title\":{},\"description\":{\"35\":{}}}],[\"out-of-stock→in-stock\",{\"_index\":371,\"title\":{},\"description\":{\"35\":{}}}],[\"output\",{\"_index\":180,\"title\":{},\"description\":{\"12\":{}}}],[\"overridden\",{\"_index\":238,\"title\":{},\"description\":{\"23\":{}}}],[\"overview\",{\"_index\":212,\"title\":{},\"description\":{\"16\":{}}}],[\"page\",{\"_index\":2,\"title\":{\"0\":{}},\"description\":{\"0\":{},\"4\":{},\"5\":{},\"6\":{},\"7\":{},\"8\":{},\"9\":{},\"11\":{},\"12\":{},\"13\":{},\"14\":{},\"15\":{},\"16\":{},\"20\":{},\"22\":{},\"29\":{}}}],[\"paramet\",{\"_index\":259,\"title\":{},\"description\":{\"31\":{}}}],[\"pars\",{\"_index\":279,\"title\":{},\"description\":{\"31\":{}}}],[\"pass\",{\"_index\":203,\"title\":{},\"description\":{\"13\":{},\"14\":{},\"31\":{}}}],[\"past\",{\"_index\":419,\"title\":{},\"description\":{\"36\":{}}}],[\"path\",{\"_index\":341,\"title\":{},\"description\":{\"35\":{}}}],[\"pattern\",{\"_index\":247,\"title\":{},\"description\":{\"28\":{}}}],[\"paus\",{\"_index\":103,\"title\":{},\"description\":{\"6\":{}}}],[\"perfect\",{\"_index\":143,\"title\":{},\"description\":{\"12\":{}}}],[\"perform\",{\"_index\":218,\"title\":{},\"description\":{\"17\":{}}}],[\"place\",{\"_index\":319,\"title\":{},\"description\":{\"35\":{}}}],[\"placehold\",{\"_index\":184,\"title\":{},\"description\":{\"12\":{}}}],[\"plain\",{\"_index\":155,\"title\":{},\"description\":{\"12\":{},\"30\":{}}}],[\"platform\",{\"_index\":236,\"title\":{},\"description\":{\"23\":{}}}],[\"plugin\",{\"_index\":290,\"title\":{\"34\":{},\"35\":{}},\"description\":{\"35\":{},\"36\":{}}}],[\"plugin'\",{\"_index\":320,\"title\":{},\"description\":{\"35\":{}}}],[\"point\",{\"_index\":139,\"title\":{},\"description\":{\"12\":{}}}],[\"popular\",{\"_index\":235,\"title\":{},\"description\":{\"23\":{}}}],[\"port\",{\"_index\":42,\"title\":{},\"description\":{\"2\":{}}}],[\"post\",{\"_index\":342,\"title\":{},\"description\":{\"35\":{}}}],[\"prefer\",{\"_index\":75,\"title\":{},\"description\":{\"4\":{},\"17\":{}}}],[\"prefix\",{\"_index\":158,\"title\":{},\"description\":{\"12\":{}}}],[\"previou\",{\"_index\":150,\"title\":{},\"description\":{\"12\":{}}}],[\"price\",{\"_index\":360,\"title\":{},\"description\":{\"35\":{}}}],[\"price_change_max\",{\"_index\":379,\"title\":{},\"description\":{\"35\":{}}}],[\"price_change_min\",{\"_index\":376,\"title\":{},\"description\":{\"35\":{}}}],[\"price_change_threshold_perc\",{\"_index\":382,\"title\":{},\"description\":{\"35\":{}}}],[\"processor\",{\"_index\":87,\"title\":{\"35\":{}},\"description\":{\"6\":{},\"31\":{},\"35\":{},\"36\":{}}}],[\"processor'\",{\"_index\":307,\"title\":{},\"description\":{\"35\":{}}}],[\"processor-specif\",{\"_index\":297,\"title\":{},\"description\":{\"35\":{}}}],[\"processor_config_<nam\",{\"_index\":415,\"title\":{},\"description\":{\"36\":{}}}],[\"processor_config_<processor_nam\",{\"_index\":329,\"title\":{},\"description\":{\"35\":{}}}],[\"processor_config_my_processor\",{\"_index\":336,\"title\":{},\"description\":{\"35\":{}}}],[\"processor_config_restock_diff\",{\"_index\":330,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"product\",{\"_index\":359,\"title\":{},\"description\":{\"35\":{}}}],[\"properti\",{\"_index\":338,\"title\":{},\"description\":{\"35\":{}}}],[\"provid\",{\"_index\":55,\"title\":{},\"description\":{\"3\":{},\"26\":{},\"36\":{}}}],[\"proxi\",{\"_index\":108,\"title\":{},\"description\":{\"6\":{},\"30\":{},\"31\":{}}}],[\"put\",{\"_index\":401,\"title\":{},\"description\":{\"35\":{}}}],[\"python\",{\"_index\":20,\"title\":{},\"description\":{\"0\":{}}}],[\"queri\",{\"_index\":128,\"title\":{},\"description\":{\"11\":{},\"13\":{},\"31\":{}}}],[\"quickli\",{\"_index\":249,\"title\":{},\"description\":{\"28\":{}}}],[\"raw\",{\"_index\":178,\"title\":{},\"description\":{\"12\":{}}}],[\"read\",{\"_index\":390,\"title\":{},\"description\":{\"35\":{}}}],[\"recent\",{\"_index\":146,\"title\":{},\"description\":{\"12\":{},\"14\":{}}}],[\"recheck\",{\"_index\":222,\"title\":{},\"description\":{\"17\":{},\"20\":{}}}],[\"recreat\",{\"_index\":397,\"title\":{},\"description\":{\"35\":{}}}],[\"red\",{\"_index\":166,\"title\":{},\"description\":{\"12\":{}}}],[\"redoc\",{\"_index\":315,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"relat\",{\"_index\":122,\"title\":{},\"description\":{\"9\":{},\"17\":{}}}],[\"remov\",{\"_index\":156,\"title\":{},\"description\":{\"12\":{},\"22\":{},\"35\":{}}}],[\"replac\",{\"_index\":244,\"title\":{\"26\":{}},\"description\":{\"26\":{}}}],[\"repres\",{\"_index\":68,\"title\":{},\"description\":{\"4\":{}}}],[\"request\",{\"_index\":53,\"title\":{},\"description\":{\"3\":{},\"31\":{},\"35\":{}}}],[\"requir\",{\"_index\":54,\"title\":{},\"description\":{\"3\":{},\"6\":{},\"36\":{}}}],[\"respons\",{\"_index\":391,\"title\":{},\"description\":{\"35\":{}}}],[\"rest\",{\"_index\":6,\"title\":{},\"description\":{\"0\":{}}}],[\"restock\",{\"_index\":417,\"title\":{},\"description\":{\"36\":{}}}],[\"restock/pric\",{\"_index\":386,\"title\":{},\"description\":{\"35\":{}}}],[\"restock_diff\",{\"_index\":90,\"title\":{},\"description\":{\"6\":{},\"35\":{}}}],[\"retriev\",{\"_index\":63,\"title\":{},\"description\":{\"4\":{},\"7\":{},\"13\":{},\"15\":{},\"20\":{},\"32\":{},\"35\":{}}}],[\"return\",{\"_index\":80,\"title\":{},\"description\":{\"5\":{},\"7\":{},\"8\":{},\"12\":{},\"18\":{},\"24\":{},\"33\":{},\"36\":{}}}],[\"review\",{\"_index\":144,\"title\":{},\"description\":{\"12\":{}}}],[\"rich\",{\"_index\":161,\"title\":{},\"description\":{\"12\":{}}}],[\"rise\",{\"_index\":380,\"title\":{},\"description\":{\"35\":{}}}],[\"rout\",{\"_index\":356,\"title\":{},\"description\":{\"35\":{}}}],[\"run\",{\"_index\":40,\"title\":{},\"description\":{\"2\":{}}}],[\"same\",{\"_index\":119,\"title\":{},\"description\":{\"8\":{},\"35\":{}}}],[\"sampl\",{\"_index\":303,\"title\":{},\"description\":{\"35\":{}}}],[\"scan\",{\"_index\":300,\"title\":{},\"description\":{\"35\":{}}}],[\"schema\",{\"_index\":260,\"title\":{},\"description\":{\"31\":{},\"35\":{},\"36\":{}}}],[\"schema-accur\",{\"_index\":414,\"title\":{},\"description\":{\"36\":{}}}],[\"schema-valid\",{\"_index\":404,\"title\":{},\"description\":{\"35\":{}}}],[\"schema.org\",{\"_index\":363,\"title\":{},\"description\":{\"35\":{}}}],[\"search\",{\"_index\":246,\"title\":{\"28\":{},\"29\":{}},\"description\":{\"28\":{},\"29\":{}}}],[\"second-most-rec\",{\"_index\":151,\"title\":{},\"description\":{\"12\":{}}}],[\"section\",{\"_index\":354,\"title\":{},\"description\":{\"35\":{}}}],[\"see\",{\"_index\":316,\"title\":{},\"description\":{\"35\":{}}}],[\"serv\",{\"_index\":237,\"title\":{},\"description\":{\"23\":{}}}],[\"servic\",{\"_index\":192,\"title\":{},\"description\":{\"12\":{},\"23\":{}}}],[\"set\",{\"_index\":28,\"title\":{},\"description\":{\"1\":{},\"4\":{},\"6\":{},\"7\":{},\"13\":{},\"17\":{},\"20\":{},\"23\":{},\"30\":{},\"31\":{},\"35\":{}}}],[\"ship\",{\"_index\":358,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"simpl\",{\"_index\":13,\"title\":{},\"description\":{\"0\":{}}}],[\"simpli\",{\"_index\":31,\"title\":{},\"description\":{\"1\":{}}}],[\"simultan\",{\"_index\":256,\"title\":{},\"description\":{\"30\":{}}}],[\"singl\",{\"_index\":69,\"title\":{\"7\":{},\"14\":{},\"20\":{}},\"description\":{\"4\":{},\"6\":{},\"8\":{},\"11\":{},\"14\":{},\"19\":{}}}],[\"skip\",{\"_index\":190,\"title\":{},\"description\":{\"12\":{},\"31\":{}}}],[\"slack\",{\"_index\":232,\"title\":{},\"description\":{\"23\":{}}}],[\"snapshot\",{\"_index\":127,\"title\":{\"12\":{},\"13\":{},\"14\":{}},\"description\":{\"11\":{},\"12\":{},\"13\":{},\"14\":{}}}],[\"some_opt\",{\"_index\":339,\"title\":{},\"description\":{\"35\":{}}}],[\"sourc\",{\"_index\":346,\"title\":{},\"description\":{\"35\":{}}}],[\"spec\",{\"_index\":310,\"title\":{\"36\":{}},\"description\":{\"35\":{},\"36\":{}}}],[\"special\",{\"_index\":261,\"title\":{},\"description\":{\"31\":{}}}],[\"specif\",{\"_index\":145,\"title\":{},\"description\":{\"12\":{},\"28\":{},\"31\":{},\"35\":{},\"36\":{}}}],[\"standard\",{\"_index\":392,\"title\":{},\"description\":{\"35\":{}}}],[\"start\",{\"_index\":22,\"title\":{},\"description\":{\"0\":{}}}],[\"startup\",{\"_index\":299,\"title\":{},\"description\":{\"35\":{}}}],[\"state\",{\"_index\":288,\"title\":{},\"description\":{\"33\":{}}}],[\"static\",{\"_index\":411,\"title\":{},\"description\":{\"36\":{}}}],[\"statist\",{\"_index\":282,\"title\":{},\"description\":{\"32\":{}}}],[\"statu\",{\"_index\":113,\"title\":{},\"description\":{\"7\":{},\"17\":{},\"20\":{},\"32\":{}}}],[\"stock\",{\"_index\":373,\"title\":{},\"description\":{\"35\":{}}}],[\"string\",{\"_index\":101,\"title\":{},\"description\":{\"6\":{},\"35\":{}}}],[\"structur\",{\"_index\":120,\"title\":{},\"description\":{\"8\":{},\"35\":{}}}],[\"support\",{\"_index\":227,\"title\":{},\"description\":{\"23\":{},\"31\":{}}}],[\"swagger\",{\"_index\":313,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"syntax\",{\"_index\":240,\"title\":{},\"description\":{\"23\":{}}}],[\"system\",{\"_index\":199,\"title\":{\"32\":{},\"33\":{}},\"description\":{\"13\":{},\"32\":{},\"33\":{},\"35\":{}}}],[\"systeminfo\",{\"_index\":289,\"title\":{},\"description\":{\"33\":{}}}],[\"tab\",{\"_index\":29,\"title\":{},\"description\":{\"1\":{}}}],[\"tag\",{\"_index\":10,\"title\":{\"17\":{},\"18\":{},\"19\":{},\"20\":{},\"21\":{},\"22\":{}},\"description\":{\"0\":{},\"6\":{},\"12\":{},\"17\":{},\"18\":{},\"19\":{},\"20\":{},\"21\":{},\"23\":{},\"28\":{},\"30\":{},\"31\":{}}}],[\"tag/group\",{\"_index\":224,\"title\":{},\"description\":{\"19\":{},\"22\":{}}}],[\"tag/{uuid\",{\"_index\":225,\"title\":{},\"description\":{\"20\":{},\"21\":{},\"22\":{}}}],[\"tag_uuid\",{\"_index\":262,\"title\":{},\"description\":{\"31\":{}}}],[\"tags/group\",{\"_index\":99,\"title\":{},\"description\":{\"6\":{},\"18\":{}}}],[\"text\",{\"_index\":154,\"title\":{},\"description\":{\"12\":{},\"13\":{},\"29\":{},\"30\":{},\"35\":{}}}],[\"text_json_diff\",{\"_index\":91,\"title\":{},\"description\":{\"6\":{}}}],[\"threshold\",{\"_index\":396,\"title\":{},\"description\":{\"35\":{}}}],[\"time\",{\"_index\":140,\"title\":{},\"description\":{\"12\":{},\"13\":{}}}],[\"time_between_check\",{\"_index\":102,\"title\":{},\"description\":{\"6\":{}}}],[\"timestamp\",{\"_index\":124,\"title\":{},\"description\":{\"10\":{},\"11\":{},\"12\":{},\"13\":{},\"14\":{}}}],[\"titl\",{\"_index\":248,\"title\":{},\"description\":{\"28\":{},\"29\":{}}}],[\"to_timestamp\",{\"_index\":149,\"title\":{},\"description\":{\"12\":{}}}],[\"total\",{\"_index\":284,\"title\":{},\"description\":{\"32\":{}}}],[\"track\",{\"_index\":374,\"title\":{},\"description\":{\"35\":{}}}],[\"trigger\",{\"_index\":385,\"title\":{},\"description\":{\"35\":{}}}],[\"true\",{\"_index\":266,\"title\":{},\"description\":{\"31\":{},\"35\":{}}}],[\"tweak\",{\"_index\":193,\"title\":{},\"description\":{\"12\":{}}}],[\"two\",{\"_index\":134,\"title\":{\"12\":{}},\"description\":{\"12\":{},\"13\":{}}}],[\"type\",{\"_index\":267,\"title\":{},\"description\":{\"31\":{},\"35\":{}}}],[\"ui\",{\"_index\":314,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"unchang\",{\"_index\":400,\"title\":{},\"description\":{\"35\":{}}}],[\"under\",{\"_index\":27,\"title\":{},\"description\":{\"1\":{}}}],[\"unix\",{\"_index\":153,\"title\":{},\"description\":{\"12\":{}}}],[\"unlik\",{\"_index\":410,\"title\":{},\"description\":{\"36\":{}}}],[\"updat\",{\"_index\":64,\"title\":{\"8\":{},\"21\":{}},\"description\":{\"4\":{},\"8\":{},\"21\":{},\"35\":{}}}],[\"uptim\",{\"_index\":286,\"title\":{},\"description\":{\"32\":{}}}],[\"url\",{\"_index\":38,\"title\":{\"2\":{},\"24\":{},\"25\":{},\"26\":{},\"27\":{},\"31\":{}},\"description\":{\"2\":{},\"4\":{},\"6\":{},\"24\":{},\"25\":{},\"26\":{},\"27\":{},\"28\":{},\"29\":{},\"30\":{},\"31\":{},\"35\":{},\"36\":{}}}],[\"url>/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/histori\",{\"_index\":51,\"title\":{},\"description\":{\"2\":{}}}],[\"us\",{\"_index\":45,\"title\":{},\"description\":{\"2\":{},\"8\":{},\"11\":{},\"12\":{},\"13\":{},\"14\":{},\"15\":{},\"17\":{},\"21\":{},\"23\":{},\"28\":{},\"31\":{},\"35\":{},\"36\":{}}}],[\"uuid\",{\"_index\":100,\"title\":{},\"description\":{\"6\":{},\"35\":{}}}],[\"valid\",{\"_index\":308,\"title\":{},\"description\":{\"35\":{}}}],[\"valu\",{\"_index\":197,\"title\":{},\"description\":{\"13\":{},\"35\":{}}}],[\"variou\",{\"_index\":228,\"title\":{},\"description\":{\"23\":{}}}],[\"version\",{\"_index\":47,\"title\":{},\"description\":{\"2\":{},\"12\":{},\"32\":{}}}],[\"via\",{\"_index\":258,\"title\":{},\"description\":{\"31\":{},\"35\":{}}}],[\"visual\",{\"_index\":209,\"title\":{},\"description\":{\"15\":{}}}],[\"watch\",{\"_index\":8,\"title\":{\"4\":{},\"5\":{},\"6\":{},\"7\":{},\"8\":{},\"9\":{},\"10\":{},\"11\":{},\"16\":{},\"29\":{},\"31\":{}},\"description\":{\"0\":{},\"4\":{},\"5\":{},\"6\":{},\"7\":{},\"8\":{},\"9\":{},\"10\":{},\"11\":{},\"12\":{},\"13\":{},\"14\":{},\"15\":{},\"16\":{},\"17\":{},\"20\":{},\"22\":{},\"23\":{},\"28\":{},\"29\":{},\"30\":{},\"31\":{},\"32\":{},\"35\":{}}}],[\"watch/{uuid\",{\"_index\":116,\"title\":{},\"description\":{\"7\":{},\"8\":{},\"9\":{}}}],[\"watch/{uuid}/difference/{from_timestamp}/{to_timestamp\",{\"_index\":195,\"title\":{},\"description\":{\"12\":{}}}],[\"watch/{uuid}/favicon\",{\"_index\":213,\"title\":{},\"description\":{\"16\":{}}}],[\"watch/{uuid}/histori\",{\"_index\":131,\"title\":{},\"description\":{\"11\":{}}}],[\"watch/{uuid}/history/{timestamp\",{\"_index\":204,\"title\":{},\"description\":{\"14\":{}}}],[\"web\",{\"_index\":1,\"title\":{\"0\":{}},\"description\":{\"4\":{},\"5\":{},\"6\":{},\"7\":{},\"8\":{},\"9\":{},\"11\":{},\"12\":{},\"14\":{},\"15\":{},\"16\":{},\"20\":{},\"22\":{},\"29\":{}}}],[\"webhook\",{\"_index\":233,\"title\":{},\"description\":{\"23\":{}}}],[\"without\",{\"_index\":182,\"title\":{},\"description\":{\"12\":{},\"35\":{}}}],[\"word-level\",{\"_index\":167,\"title\":{},\"description\":{\"12\":{}}}],[\"word_diff=fals\",{\"_index\":175,\"title\":{},\"description\":{\"12\":{}}}],[\"word_diff=tru\",{\"_index\":171,\"title\":{},\"description\":{\"12\":{}}}],[\"work\",{\"_index\":298,\"title\":{},\"description\":{\"35\":{}}}],[\"write\",{\"_index\":318,\"title\":{},\"description\":{\"35\":{}}}],[\"x\",{\"_index\":347,\"title\":{},\"description\":{\"35\":{}}}],[\"x-api-key\",{\"_index\":58,\"title\":{},\"description\":{\"3\":{},\"35\":{}}}],[\"x-code-sampl\",{\"_index\":343,\"title\":{},\"description\":{\"35\":{}}}],[\"yaml\",{\"_index\":334,\"title\":{},\"description\":{\"35\":{},\"36\":{}}}],[\"ye\",{\"_index\":273,\"title\":{},\"description\":{\"31\":{}}}],[\"your_api_key\",{\"_index\":59,\"title\":{},\"description\":{\"3\":{},\"35\":{}}}]],\"pipeline\":[]}},\"options\":{}};\n\n      var container = document.getElementById('redoc');\n      Redoc.hydrate(__redoc_state, container);\n\n      </script>\n</body>\n\n</html>\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"changedetection-api-docs\",\n  \"version\": \"1.0.0\",\n  \"description\": \"API documentation generation for changedetection.io\",\n  \"private\": true,\n  \"scripts\": {\n    \"build-docs\": \"redocly build-docs api-spec.yaml --output api_v1/index.html\"\n  },\n  \"devDependencies\": {\n    \"@redocly/cli\": \"^1.34.5\"\n  }\n}"
  },
  {
    "path": "requirements.txt",
    "content": "feedgen~=1.0\nfeedparser~=6.0  # For parsing RSS/Atom feeds\nflask-compress\nflask-login>=0.6.3\nflask-paginate\nflask-socketio>=5.6.1,<6 # Re #3910\nflask>=3.1,<4\nflask_cors # For the Chrome extension to operate\nflask_restful\nflask_wtf~=1.2\ninscriptis~=2.2\npython-engineio>=4.9.0,<5\npython-socketio>=5.11.0,<6\npytz\ntimeago~=1.0\nvalidators~=0.35\nwerkzeug==3.1.6\n\n\n# Set these versions together to avoid a RequestsDependencyWarning\n# >= 2.26 also adds Brotli support if brotli is installed\nbrotli~=1.2\nrequests[socks]\nrequests-file\n\n# urllib3==1.26.19  # Unpinned - let requests decide compatible version\n# If specific version needed for security, use urllib3>=1.26.19,<3.0\nchardet>2.3.0\n\nwtforms~=3.2\njsonpath-ng~=1.8.0\n\n# Fast JSON serialization for better performance\norjson~=3.11\n\n# dnspython - Used by paho-mqtt for MQTT broker resolution  \n# Version pin removed since eventlet (which required the specific 2.6.1 pin) has been eliminated\n# paho-mqtt will install compatible dnspython version automatically\n\n# jq not available on Windows so must be installed manually\n\n# Notification library\napprise==1.9.8\n\ndiff_match_patch\n\n# Lightweight URL linkifier for notifications\nlinkify-it-py\n\n# - Needed for apprise/spush, and maybe others? hopefully doesnt trigger a rust compile.\n# - Requires extra wheel for rPi, adds build time for arm/v8 which is not in piwheels\n# Pinned to 44.x for ARM compatibility and sslyze compatibility (sslyze requires <45) and (45.x may not have pre-built ARM wheels)\n# Also pinned because dependabot wants specific versions\ncryptography==44.0.0\n\n# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315\n# use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814\npaho-mqtt!=2.0.*\n\n# Used for CSS filtering, JSON extraction from HTML\nbeautifulsoup4>=4.0.0,<=4.14.3\n\n# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.\n# #2328 - 5.2.0 and 5.2.1 had extra CPU flag CFLAGS set which was not compatible on older hardware\n#         It could be advantageous to run its own pypi package here with those performance flags set\n#         https://bugs.launchpad.net/lxml/+bug/2059910/comments/16\nlxml >=4.8.0,!=5.2.0,!=5.2.1,<7\n\n# XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable\n# Consider updating to latest stable version periodically\nelementpath==5.1.1\n\n# For fast image comparison in screenshot change detection\n# opencv-python-headless is OPTIONAL (excluded from requirements.txt)\n# - Installed conditionally via Dockerfile (skipped on arm/v7 and arm/v8 due to long build times)\n# - Pixelmatch is used as fallback when OpenCV is unavailable\n# - To install manually: pip install opencv-python-headless>=4.8.0.76\n\nselenium~=4.31.0\n\n# Templating, so far just in the URLs but in the future can be for the notifications also\njinja2~=3.1\narrow\nopenpyxl\n# https://peps.python.org/pep-0508/#environment-markers\n# https://github.com/dgtlmoon/changedetection.io/pull/1009\njq~=1.3; python_version >= \"3.8\" and sys_platform == \"darwin\"\njq~=1.3; python_version >= \"3.8\" and sys_platform == \"linux\"\n\n# playwright is installed at Dockerfile build time because it's not available on all platforms\n\npyppeteer-ng==2.0.0rc13\npyppeteerstealth>=0.0.4\n\n# Include pytest, so if theres a support issue we can ask them to run these tests on their setup\npytest ~=9.0\npytest-flask ~=1.3\npytest-mock ~=3.15\n\n# OpenAPI validation support\nopenapi-core[flask] ~= 0.22\n\nloguru\n\n# For scraping all possible metadata relating to products so we can do better restock detection\nextruct\n\n# For cleaning up unknown currency formats\nbabel\n# For internationalization (i18n) support\nFlask-Babel>=4.0.0\n\nlevenshtein\n\n# Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096\ngreenlet >= 3.0.3\n\n# Optional: Used for high-concurrency SocketIO mode (via SOCKETIO_MODE=gevent)\n# Note: gevent has cross-platform limitations (Windows 1024 socket limit, macOS ARM build issues)\n# Default SOCKETIO_MODE=threading is recommended for better compatibility\ngevent\n\nreferencing  # Don't pin — jsonschema-path (required by openapi-core>=0.18) caps referencing<0.37.0, so pinning 0.37.0 forces openapi-core back to 0.17.2. Revisit once jsonschema-path>=0.3.5 relaxes the cap.\n\n# For conditions\npanzi-json-logic\n# For conditions - extracted number from a body of text\nprice-parser\n\n# flask_socket_io - incorrect package name, already have flask-socketio above\n\n# Lightweight MIME type detection (saves ~14MB memory vs python-magic/libmagic)\n# Used for detecting correct favicon type and content-type detection\npuremagic\n\n# Scheduler - Windows seemed to miss a lot of default timezone info (even \"UTC\" !)\ntzdata\n\n#typing_extensions ==4.8.0\n\npluggy ~= 1.6\n\n# Needed for testing, cross-platform for process and system monitoring\npsutil==7.2.2\n\nruff >= 0.11.2\npre_commit >= 4.2.0\n\n# For events between checking and socketio updates\nblinker\npytest-xdist\n\n\n"
  },
  {
    "path": "runtime.txt",
    "content": "python-3.11.5"
  },
  {
    "path": "setup.cfg",
    "content": "# Translation configuration for changedetection.io\n# See changedetectionio/translations/README.md for full documentation on updating translations\n\n[extract_messages]\n# Extract translatable strings from source code\nmapping_file = babel.cfg\noutput_file = changedetectionio/translations/messages.pot\ninput_paths = changedetectionio\nkeywords = _ _l gettext\n# Options to reduce unnecessary changes in .pot files\nsort_by_file = true\nwidth = 120\nadd_location = file\n\n[update_catalog]\n# Update existing .po files with new strings from .pot\n# Note: Omitting 'locale' makes Babel auto-discover all catalogs in output_dir\ninput_file = changedetectionio/translations/messages.pot\noutput_dir = changedetectionio/translations\ndomain = messages\n# Options for consistent formatting\nwidth = 120\nno_fuzzy_matching = true\n\n[compile_catalog]\n# Compile .po files to .mo binary format\ndirectory = changedetectionio/translations\ndomain = messages\n"
  },
  {
    "path": "setup.py",
    "content": "#!/usr/bin/env python\nimport codecs\nimport os.path\nimport re\nimport sys\n\nfrom setuptools import setup, find_packages\nfrom setuptools.command.build_py import build_py\nimport shutil\n\nhere = os.path.abspath(os.path.dirname(__file__))\n\n\ndef read(*parts):\n    return codecs.open(os.path.join(here, *parts), 'r').read()\n\n\ndef find_version(*file_paths):\n    version_file = read(*file_paths)\n    version_match = re.search(r\"^__version__ = ['\\\"]([^'\\\"]*)['\\\"]\",\n                              version_file, re.M)\n    if version_match:\n        return version_match.group(1)\n    raise RuntimeError(\"Unable to find version string.\")\n\n\nclass BuildPyCommand(build_py):\n    \"\"\"Custom build command to copy api-spec.yaml to the package.\"\"\"\n    def run(self):\n        build_py.run(self)\n        # Ensure the docs directory exists in the build output\n        docs_dir = os.path.join(self.build_lib, 'changedetectionio', 'docs')\n        os.makedirs(docs_dir, exist_ok=True)\n        # Copy api-spec.yaml to the package\n        shutil.copy(\n            os.path.join(here, 'docs', 'api-spec.yaml'),\n            os.path.join(docs_dir, 'api-spec.yaml')\n        )\n\n\ninstall_requires = open('requirements.txt').readlines()\n\nsetup(\n    name='changedetection.io',\n    version=find_version(\"changedetectionio\", \"__init__.py\"),\n    description='Website change detection and monitoring service, detect changes to web pages and send alerts/notifications.',\n    long_description=open('README-pip.md').read(),\n    long_description_content_type='text/markdown',\n    keywords='website change monitor for changes notification change detection '\n             'alerts tracking website tracker change alert website and monitoring',\n    entry_points={\"console_scripts\": [\"changedetection.io=changedetectionio:main\"]},\n    zip_safe=True,\n    scripts=[\"changedetection.py\"],\n    author='dgtlmoon',\n    url='https://changedetection.io',\n    packages=find_packages(include=['changedetectionio', 'changedetectionio.*']),\n    include_package_data=True,\n    install_requires=install_requires,\n    cmdclass={'build_py': BuildPyCommand},\n    license=\"Apache License 2.0\",\n    python_requires=\">= 3.10\",\n    classifiers=['Intended Audience :: Customer Service',\n                 'Intended Audience :: Developers',\n                 'Intended Audience :: Education',\n                 'Intended Audience :: End Users/Desktop',\n                 'Intended Audience :: Financial and Insurance Industry',\n                 'Intended Audience :: Healthcare Industry',\n                 'Intended Audience :: Information Technology',\n                 'Intended Audience :: Legal Industry',\n                 'Intended Audience :: Manufacturing',\n                 'Intended Audience :: Other Audience',\n                 'Intended Audience :: Religion',\n                 'Intended Audience :: Science/Research',\n                 'Intended Audience :: System Administrators',\n                 'Intended Audience :: Telecommunications Industry',\n                 'Topic :: Education',\n                 'Topic :: Internet',\n                 'Topic :: Internet :: WWW/HTTP :: Indexing/Search',\n                 'Topic :: Internet :: WWW/HTTP :: Site Management',\n                 'Topic :: Internet :: WWW/HTTP :: Site Management :: Link Checking',\n                 'Topic :: Internet :: WWW/HTTP :: Browsers',\n                 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',\n                 'Topic :: Office/Business',\n                 'Topic :: Other/Nonlisted Topic',\n                 'Topic :: Scientific/Engineering :: Information Analysis',\n                 'Topic :: Text Processing :: Markup :: HTML',\n                 'Topic :: Utilities'\n                 ],\n)\n"
  }
]