mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-11-04 00:27:48 +00:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			0.45.6
			...
			1857-edit-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					996541b817 | ||
| 
						 | 
					ee924e1d0f | 
							
								
								
									
										4
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							@@ -29,8 +29,8 @@ jobs:
 | 
			
		||||
          docker network create changedet-network
 | 
			
		||||
 | 
			
		||||
          # Selenium+browserless
 | 
			
		||||
          docker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome:4
 | 
			
		||||
          docker run --network changedet-network -d --hostname browserless -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.60-chrome-stable
 | 
			
		||||
          docker run --network changedet-network -d --hostname selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome-debug:3.141.59
 | 
			
		||||
          docker run --network changedet-network -d --hostname browserless -e "FUNCTION_BUILT_INS=[\"fs\",\"crypto\"]" -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.53-chrome-stable
 | 
			
		||||
 | 
			
		||||
      - name: Build changedetection.io container for testing
 | 
			
		||||
        run: |         
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							@@ -1,5 +1,5 @@
 | 
			
		||||
# pip dependencies install stage
 | 
			
		||||
FROM python:3.11-slim-bookworm as builder
 | 
			
		||||
FROM python:3.11-slim-bullseye as builder
 | 
			
		||||
 | 
			
		||||
# See `cryptography` pin comment in requirements.txt
 | 
			
		||||
ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1
 | 
			
		||||
@@ -20,23 +20,19 @@ WORKDIR /install
 | 
			
		||||
 | 
			
		||||
COPY requirements.txt /requirements.txt
 | 
			
		||||
 | 
			
		||||
# Instructing pip to fetch wheels from piwheels.org" on ARMv6 and ARMv7 machines
 | 
			
		||||
RUN if [ "$(dpkg --print-architecture)" = "armhf" ] || [ "$(dpkg --print-architecture)" = "armel" ]; then \
 | 
			
		||||
      printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf; \
 | 
			
		||||
    fi;
 | 
			
		||||
 | 
			
		||||
RUN pip install --target=/dependencies -r /requirements.txt
 | 
			
		||||
 | 
			
		||||
# Playwright is an alternative to Selenium
 | 
			
		||||
# Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing
 | 
			
		||||
# https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported)
 | 
			
		||||
RUN pip install --target=/dependencies playwright~=1.39 \
 | 
			
		||||
RUN pip install --target=/dependencies playwright~=1.27.1 \
 | 
			
		||||
    || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled."
 | 
			
		||||
 | 
			
		||||
# Final image stage
 | 
			
		||||
FROM python:3.11-slim-bookworm
 | 
			
		||||
FROM python:3.11-slim-bullseye
 | 
			
		||||
 | 
			
		||||
RUN apt-get update && apt-get install -y --no-install-recommends \
 | 
			
		||||
    libssl1.1 \
 | 
			
		||||
    libxslt1.1 \
 | 
			
		||||
    # For pdftohtml
 | 
			
		||||
    poppler-utils \
 | 
			
		||||
 
 | 
			
		||||
@@ -232,13 +232,6 @@ See the wiki https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configura
 | 
			
		||||
 | 
			
		||||
Raspberry Pi and linux/arm/v6 linux/arm/v7 arm64 devices are supported! See the wiki for [details](https://github.com/dgtlmoon/changedetection.io/wiki/Fetching-pages-with-WebDriver)
 | 
			
		||||
 | 
			
		||||
## Import support
 | 
			
		||||
 | 
			
		||||
Easily [import your list of websites to watch for changes in Excel .xslx file format](https://changedetection.io/tutorial/how-import-your-website-change-detection-lists-excel), or paste in lists of website URLs as plaintext. 
 | 
			
		||||
 | 
			
		||||
Excel import is recommended - that way you can better organise tags/groups of websites and other features.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## API Support
 | 
			
		||||
 | 
			
		||||
Supports managing the website watch list [via our API](https://changedetection.io/docs/api_v1/index.html)
 | 
			
		||||
 
 | 
			
		||||
@@ -38,7 +38,7 @@ from flask_paginate import Pagination, get_page_parameter
 | 
			
		||||
from changedetectionio import html_tools
 | 
			
		||||
from changedetectionio.api import api_v1
 | 
			
		||||
 | 
			
		||||
__version__ = '0.45.6'
 | 
			
		||||
__version__ = '0.45.3'
 | 
			
		||||
 | 
			
		||||
from changedetectionio.store import BASE_URL_NOT_SET_TEXT
 | 
			
		||||
 | 
			
		||||
@@ -416,18 +416,11 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
        # Sort by last_changed and add the uuid which is usually the key..
 | 
			
		||||
        sorted_watches = []
 | 
			
		||||
        with_errors = request.args.get('with_errors') == "1"
 | 
			
		||||
        errored_count = 0
 | 
			
		||||
        search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
 | 
			
		||||
        for uuid, watch in datastore.data['watching'].items():
 | 
			
		||||
            if with_errors and not watch.get('last_error'):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if limit_tag and not limit_tag in watch['tags']:
 | 
			
		||||
                    continue
 | 
			
		||||
            if watch.get('last_error'):
 | 
			
		||||
                errored_count += 1
 | 
			
		||||
                
 | 
			
		||||
 | 
			
		||||
            if search_q:
 | 
			
		||||
                if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
 | 
			
		||||
                    sorted_watches.append(watch)
 | 
			
		||||
@@ -449,7 +442,6 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                                 active_tag=limit_tag,
 | 
			
		||||
                                 app_rss_token=datastore.data['settings']['application']['rss_access_token'],
 | 
			
		||||
                                 datastore=datastore,
 | 
			
		||||
                                 errored_count=errored_count,
 | 
			
		||||
                                 form=form,
 | 
			
		||||
                                 guid=datastore.data['app_guid'],
 | 
			
		||||
                                 has_proxies=datastore.proxy_list,
 | 
			
		||||
@@ -630,6 +622,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
            if request.args.get('unpause_on_save'):
 | 
			
		||||
                extra_update_obj['paused'] = False
 | 
			
		||||
 | 
			
		||||
            # Re #110, if they submit the same as the default value, set it to None, so we continue to follow the default
 | 
			
		||||
            # Assume we use the default value, unless something relevant is different, then use the form value
 | 
			
		||||
            # values could be None, 0 etc.
 | 
			
		||||
@@ -715,6 +708,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
            # Only works reliably with Playwright
 | 
			
		||||
            visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and is_html_webdriver
 | 
			
		||||
 | 
			
		||||
            output = render_template("edit.html",
 | 
			
		||||
                                     available_processors=processors.available_processors(),
 | 
			
		||||
                                     browser_steps_config=browser_step_ui_config,
 | 
			
		||||
@@ -822,7 +816,6 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        from . import forms
 | 
			
		||||
 | 
			
		||||
        if request.method == 'POST':
 | 
			
		||||
 | 
			
		||||
            from .importer import import_url_list, import_distill_io_json
 | 
			
		||||
 | 
			
		||||
            # URL List import
 | 
			
		||||
@@ -846,32 +839,11 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                for uuid in d_importer.new_uuids:
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
 | 
			
		||||
            # XLSX importer
 | 
			
		||||
            if request.files and request.files.get('xlsx_file'):
 | 
			
		||||
                file = request.files['xlsx_file']
 | 
			
		||||
                from .importer import import_xlsx_wachete, import_xlsx_custom
 | 
			
		||||
 | 
			
		||||
                if request.values.get('file_mapping') == 'wachete':
 | 
			
		||||
                    w_importer = import_xlsx_wachete()
 | 
			
		||||
                    w_importer.run(data=file, flash=flash, datastore=datastore)
 | 
			
		||||
                else:
 | 
			
		||||
                    w_importer = import_xlsx_custom()
 | 
			
		||||
                    # Building mapping of col # to col # type
 | 
			
		||||
                    map = {}
 | 
			
		||||
                    for i in range(10):
 | 
			
		||||
                        c = request.values.get(f"custom_xlsx[col_{i}]")
 | 
			
		||||
                        v = request.values.get(f"custom_xlsx[col_type_{i}]")
 | 
			
		||||
                        if c and v:
 | 
			
		||||
                            map[int(c)] = v
 | 
			
		||||
 | 
			
		||||
                    w_importer.import_profile = map
 | 
			
		||||
                    w_importer.run(data=file, flash=flash, datastore=datastore)
 | 
			
		||||
 | 
			
		||||
                for uuid in w_importer.new_uuids:
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
 | 
			
		||||
        form = forms.importForm(formdata=request.form if request.method == 'POST' else None,
 | 
			
		||||
#                               data=default,
 | 
			
		||||
                               )
 | 
			
		||||
        # Could be some remaining, or we could be on GET
 | 
			
		||||
        form = forms.importForm(formdata=request.form if request.method == 'POST' else None)
 | 
			
		||||
        output = render_template("import.html",
 | 
			
		||||
                                 form=form,
 | 
			
		||||
                                 import_url_list_remaining="\n".join(remaining_urls),
 | 
			
		||||
@@ -885,10 +857,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
    def mark_all_viewed():
 | 
			
		||||
 | 
			
		||||
        # Save the current newest history as the most recently viewed
 | 
			
		||||
        with_errors = request.args.get('with_errors') == "1"
 | 
			
		||||
        for watch_uuid, watch in datastore.data['watching'].items():
 | 
			
		||||
            if with_errors and not watch.get('last_error'):
 | 
			
		||||
                continue
 | 
			
		||||
            datastore.set_last_viewed(watch_uuid, int(time.time()))
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
@@ -1208,7 +1177,8 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            # These files should be in our subdirectory
 | 
			
		||||
            try:
 | 
			
		||||
                # set nocache, set content-type
 | 
			
		||||
                response = make_response(send_from_directory(os.path.join(datastore_o.datastore_path, filename), "elements.json"))
 | 
			
		||||
                watch_dir = datastore_o.datastore_path + "/" + filename
 | 
			
		||||
                response = make_response(send_from_directory(filename="elements.json", directory=watch_dir, path=watch_dir + "/elements.json"))
 | 
			
		||||
                response.headers['Content-type'] = 'application/json'
 | 
			
		||||
                response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
 | 
			
		||||
                response.headers['Pragma'] = 'no-cache'
 | 
			
		||||
@@ -1296,8 +1266,6 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        # Forced recheck will skip the 'skip if content is the same' rule (, 'reprocess_existing_data': True})))
 | 
			
		||||
        tag = request.args.get('tag')
 | 
			
		||||
        uuid = request.args.get('uuid')
 | 
			
		||||
        with_errors = request.args.get('with_errors') == "1"
 | 
			
		||||
 | 
			
		||||
        i = 0
 | 
			
		||||
 | 
			
		||||
        running_uuids = []
 | 
			
		||||
@@ -1313,8 +1281,6 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            # Items that have this current tag
 | 
			
		||||
            for watch_uuid, watch in datastore.data['watching'].items():
 | 
			
		||||
                if tag in watch.get('tags', {}):
 | 
			
		||||
                    if with_errors and not watch.get('last_error'):
 | 
			
		||||
                        continue
 | 
			
		||||
                    if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
 | 
			
		||||
                        update_q.put(
 | 
			
		||||
                            queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False})
 | 
			
		||||
@@ -1325,11 +1291,8 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            # No tag, no uuid, add everything.
 | 
			
		||||
            for watch_uuid, watch in datastore.data['watching'].items():
 | 
			
		||||
                if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
 | 
			
		||||
                    if with_errors and not watch.get('last_error'):
 | 
			
		||||
                        continue
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False}))
 | 
			
		||||
                    i += 1
 | 
			
		||||
 | 
			
		||||
        flash("{} watches queued for rechecking.".format(i))
 | 
			
		||||
        return redirect(url_for('index', tag=tag))
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -23,10 +23,8 @@
 | 
			
		||||
 | 
			
		||||
from distutils.util import strtobool
 | 
			
		||||
from flask import Blueprint, request, make_response
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
from changedetectionio.store import ChangeDetectionStore
 | 
			
		||||
from changedetectionio import login_optionally_required
 | 
			
		||||
 | 
			
		||||
@@ -46,7 +44,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        # We keep the playwright session open for many minutes
 | 
			
		||||
        keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
 | 
			
		||||
        seconds_keepalive = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
 | 
			
		||||
 | 
			
		||||
        browsersteps_start_session = {'start_time': time.time()}
 | 
			
		||||
 | 
			
		||||
@@ -58,18 +56,16 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
            # Start the Playwright context, which is actually a nodejs sub-process and communicates over STDIN/STDOUT pipes
 | 
			
		||||
            io_interface_context = io_interface_context.start()
 | 
			
		||||
 | 
			
		||||
        keepalive_ms = ((keepalive_seconds + 3) * 1000)
 | 
			
		||||
        base_url = os.getenv('PLAYWRIGHT_DRIVER_URL', '')
 | 
			
		||||
        a = "?" if not '?' in base_url else '&'
 | 
			
		||||
        base_url += a + f"timeout={keepalive_ms}"
 | 
			
		||||
 | 
			
		||||
        # keep it alive for 10 seconds more than we advertise, sometimes it helps to keep it shutting down cleanly
 | 
			
		||||
        keepalive = "&timeout={}".format(((seconds_keepalive + 3) * 1000))
 | 
			
		||||
        try:
 | 
			
		||||
            browsersteps_start_session['browser'] = io_interface_context.chromium.connect_over_cdp(base_url)
 | 
			
		||||
            browsersteps_start_session['browser'] = io_interface_context.chromium.connect_over_cdp(
 | 
			
		||||
                os.getenv('PLAYWRIGHT_DRIVER_URL', '') + keepalive)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            if 'ECONNREFUSED' in str(e):
 | 
			
		||||
                return make_response('Unable to start the Playwright Browser session, is it running?', 401)
 | 
			
		||||
            else:
 | 
			
		||||
                # Other errors, bad URL syntax, bad reply etc
 | 
			
		||||
                return make_response(str(e), 401)
 | 
			
		||||
 | 
			
		||||
        proxy_id = datastore.get_preferred_proxy_for_watch(uuid=watch_uuid)
 | 
			
		||||
@@ -122,31 +118,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
        print("Starting connection with playwright - done")
 | 
			
		||||
        return {'browsersteps_session_id': browsersteps_session_id}
 | 
			
		||||
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    @browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
 | 
			
		||||
    def browser_steps_fetch_screenshot_image():
 | 
			
		||||
        from flask import (
 | 
			
		||||
            make_response,
 | 
			
		||||
            request,
 | 
			
		||||
            send_from_directory,
 | 
			
		||||
        )
 | 
			
		||||
        uuid = request.args.get('uuid')
 | 
			
		||||
        step_n = int(request.args.get('step_n'))
 | 
			
		||||
 | 
			
		||||
        watch = datastore.data['watching'].get(uuid)
 | 
			
		||||
        filename = f"step_before-{step_n}.jpeg" if request.args.get('type', '') == 'before' else f"step_{step_n}.jpeg"
 | 
			
		||||
 | 
			
		||||
        if step_n and watch and os.path.isfile(os.path.join(watch.watch_data_dir, filename)):
 | 
			
		||||
            response = make_response(send_from_directory(directory=watch.watch_data_dir, path=filename))
 | 
			
		||||
            response.headers['Content-type'] = 'image/jpeg'
 | 
			
		||||
            response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
 | 
			
		||||
            response.headers['Pragma'] = 'no-cache'
 | 
			
		||||
            response.headers['Expires'] = 0
 | 
			
		||||
            return response
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)
 | 
			
		||||
 | 
			
		||||
    # A request for an action was received
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    @browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
 | 
			
		||||
 
 | 
			
		||||
@@ -77,13 +77,13 @@ class steppable_browser_interface():
 | 
			
		||||
    def action_goto_url(self, selector=None, value=None):
 | 
			
		||||
        # self.page.set_viewport_size({"width": 1280, "height": 5000})
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        response = self.page.goto(value, timeout=0, wait_until='load')
 | 
			
		||||
        # Should be the same as the puppeteer_fetch.js methods, means, load with no timeout set (skip timeout)
 | 
			
		||||
        #and also wait for seconds ?
 | 
			
		||||
        #await page.waitForTimeout(1000);
 | 
			
		||||
        #await page.waitForTimeout(extra_wait_ms);
 | 
			
		||||
        response = self.page.goto(value, timeout=0, wait_until='commit')
 | 
			
		||||
 | 
			
		||||
        # Wait_until = commit
 | 
			
		||||
        # - `'commit'` - consider operation to be finished when network response is received and the document started loading.
 | 
			
		||||
        # Better to not use any smarts from Playwright and just wait an arbitrary number of seconds
 | 
			
		||||
        # This seemed to solve nearly all 'TimeoutErrors'
 | 
			
		||||
        print("Time to goto URL ", time.time() - now)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    def action_click_element_containing_text(self, selector=None, value=''):
 | 
			
		||||
        if not len(value.strip()):
 | 
			
		||||
@@ -99,8 +99,7 @@ class steppable_browser_interface():
 | 
			
		||||
        self.page.fill(selector, value, timeout=10 * 1000)
 | 
			
		||||
 | 
			
		||||
    def action_execute_js(self, selector, value):
 | 
			
		||||
        response = self.page.evaluate(value)
 | 
			
		||||
        return response
 | 
			
		||||
        self.page.evaluate(value)
 | 
			
		||||
 | 
			
		||||
    def action_click_element(self, selector, value):
 | 
			
		||||
        print("Clicking element")
 | 
			
		||||
@@ -139,13 +138,13 @@ class steppable_browser_interface():
 | 
			
		||||
    def action_wait_for_text(self, selector, value):
 | 
			
		||||
        import json
 | 
			
		||||
        v = json.dumps(value)
 | 
			
		||||
        self.page.wait_for_function(f'document.querySelector("body").innerText.includes({v});', timeout=30000)
 | 
			
		||||
        self.page.wait_for_function(f'document.querySelector("body").innerText.includes({v});', timeout=90000)
 | 
			
		||||
 | 
			
		||||
    def action_wait_for_text_in_element(self, selector, value):
 | 
			
		||||
        import json
 | 
			
		||||
        s = json.dumps(selector)
 | 
			
		||||
        v = json.dumps(value)
 | 
			
		||||
        self.page.wait_for_function(f'document.querySelector({s}).innerText.includes({v});', timeout=30000)
 | 
			
		||||
        self.page.wait_for_function(f'document.querySelector({s}).innerText.includes({v});', timeout=90000)
 | 
			
		||||
 | 
			
		||||
    # @todo - in the future make some popout interface to capture what needs to be set
 | 
			
		||||
    # https://playwright.dev/python/docs/api/class-keyboard
 | 
			
		||||
 
 | 
			
		||||
@@ -159,16 +159,6 @@ class Fetcher():
 | 
			
		||||
        """
 | 
			
		||||
        return {k.lower(): v for k, v in self.headers.items()}
 | 
			
		||||
 | 
			
		||||
    def browser_steps_get_valid_steps(self):
 | 
			
		||||
        if self.browser_steps is not None and len(self.browser_steps):
 | 
			
		||||
            valid_steps = filter(
 | 
			
		||||
                lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'),
 | 
			
		||||
                self.browser_steps)
 | 
			
		||||
 | 
			
		||||
            return valid_steps
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def iterate_browser_steps(self):
 | 
			
		||||
        from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface
 | 
			
		||||
        from playwright._impl._api_types import TimeoutError
 | 
			
		||||
@@ -180,7 +170,10 @@ class Fetcher():
 | 
			
		||||
        if self.browser_steps is not None and len(self.browser_steps):
 | 
			
		||||
            interface = steppable_browser_interface()
 | 
			
		||||
            interface.page = self.page
 | 
			
		||||
            valid_steps = self.browser_steps_get_valid_steps()
 | 
			
		||||
 | 
			
		||||
            valid_steps = filter(
 | 
			
		||||
                lambda s: (s['operation'] and len(s['operation']) and s['operation'] != 'Choose one' and s['operation'] != 'Goto site'),
 | 
			
		||||
                self.browser_steps)
 | 
			
		||||
 | 
			
		||||
            for step in valid_steps:
 | 
			
		||||
                step_n += 1
 | 
			
		||||
@@ -333,8 +326,9 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
            # Remove username/password if it exists in the URL or you will receive "ERR_NO_SUPPORTED_PROXIES" error
 | 
			
		||||
            # Actual authentication handled by Puppeteer/node
 | 
			
		||||
            o = urlparse(self.proxy.get('server'))
 | 
			
		||||
            proxy_url = urllib.parse.quote(o._replace(netloc="{}:{}".format(o.hostname, o.port)).geturl())
 | 
			
		||||
            browserless_function_url = f"{browserless_function_url}&--proxy-server={proxy_url}"
 | 
			
		||||
            # Remove scheme, socks5:// doesnt always work and it will autodetect anyway
 | 
			
		||||
            proxy_url = urllib.parse.quote(o._replace(netloc="{}:{}".format(o.hostname, o.port)).geturl().replace(f"{o.scheme}://", '', 1))
 | 
			
		||||
            browserless_function_url = f"{browserless_function_url}&--proxy-server={proxy_url}&dumpio=true"
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            amp = '&' if '?' in browserless_function_url else '?'
 | 
			
		||||
@@ -470,26 +464,39 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
            if len(request_headers):
 | 
			
		||||
                context.set_extra_http_headers(request_headers)
 | 
			
		||||
 | 
			
		||||
            # Listen for all console events and handle errors
 | 
			
		||||
            self.page.on("console", lambda msg: print(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}"))
 | 
			
		||||
                self.page.set_default_navigation_timeout(90000)
 | 
			
		||||
                self.page.set_default_timeout(90000)
 | 
			
		||||
 | 
			
		||||
            # Re-use as much code from browser steps as possible so its the same
 | 
			
		||||
            from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface
 | 
			
		||||
            browsersteps_interface = steppable_browser_interface()
 | 
			
		||||
            browsersteps_interface.page = self.page
 | 
			
		||||
                # Listen for all console events and handle errors
 | 
			
		||||
                self.page.on("console", lambda msg: print(f"Playwright console: Watch URL: {url} {msg.type}: {msg.text} {msg.args}"))
 | 
			
		||||
 | 
			
		||||
            response = browsersteps_interface.action_goto_url(value=url)
 | 
			
		||||
            self.headers = response.all_headers()
 | 
			
		||||
 | 
			
		||||
            if response is None:
 | 
			
		||||
            # Goto page
 | 
			
		||||
            try:
 | 
			
		||||
                # Wait_until = commit
 | 
			
		||||
                # - `'commit'` - consider operation to be finished when network response is received and the document started loading.
 | 
			
		||||
                # Better to not use any smarts from Playwright and just wait an arbitrary number of seconds
 | 
			
		||||
                # This seemed to solve nearly all 'TimeoutErrors'
 | 
			
		||||
                response = self.page.goto(url, wait_until='commit')
 | 
			
		||||
            except playwright._impl._api_types.Error as e:
 | 
			
		||||
                # Retry once - https://github.com/browserless/chrome/issues/2485
 | 
			
		||||
                # Sometimes errors related to invalid cert's and other can be random
 | 
			
		||||
                print("Content Fetcher > retrying request got error - ", str(e))
 | 
			
		||||
                time.sleep(1)
 | 
			
		||||
                response = self.page.goto(url, wait_until='commit')
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print("Content Fetcher > Other exception when page.goto", str(e))
 | 
			
		||||
                context.close()
 | 
			
		||||
                browser.close()
 | 
			
		||||
                print("Content Fetcher > Response object was none")
 | 
			
		||||
                raise EmptyReply(url=url, status_code=None)
 | 
			
		||||
                raise PageUnloadable(url=url, status_code=None, message=str(e))
 | 
			
		||||
 | 
			
		||||
            # Execute any browser steps
 | 
			
		||||
            try:
 | 
			
		||||
                extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
 | 
			
		||||
                self.page.wait_for_timeout(extra_wait * 1000)
 | 
			
		||||
 | 
			
		||||
                if self.webdriver_js_execute_code is not None and len(self.webdriver_js_execute_code):
 | 
			
		||||
                    browsersteps_interface.action_execute_js(value=self.webdriver_js_execute_code, selector=None)
 | 
			
		||||
                    self.page.evaluate(self.webdriver_js_execute_code)
 | 
			
		||||
 | 
			
		||||
            except playwright._impl._api_types.TimeoutError as e:
 | 
			
		||||
                context.close()
 | 
			
		||||
                browser.close()
 | 
			
		||||
@@ -501,26 +508,28 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
                browser.close()
 | 
			
		||||
                raise PageUnloadable(url=url, status_code=None, message=str(e))
 | 
			
		||||
 | 
			
		||||
            if response is None:
 | 
			
		||||
                context.close()
 | 
			
		||||
                browser.close()
 | 
			
		||||
                print("Content Fetcher > Response object was none")
 | 
			
		||||
                raise EmptyReply(url=url, status_code=None)
 | 
			
		||||
 | 
			
		||||
            # Run Browser Steps here
 | 
			
		||||
            self.iterate_browser_steps()
 | 
			
		||||
 | 
			
		||||
            extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
 | 
			
		||||
            self.page.wait_for_timeout(extra_wait * 1000)
 | 
			
		||||
 | 
			
		||||
            time.sleep(extra_wait)
 | 
			
		||||
 | 
			
		||||
            self.content = self.page.content()
 | 
			
		||||
            self.status_code = response.status
 | 
			
		||||
 | 
			
		||||
            if self.status_code != 200 and not ignore_status_codes:
 | 
			
		||||
                raise Non200ErrorCodeReceived(url=url, status_code=self.status_code)
 | 
			
		||||
 | 
			
		||||
            if len(self.page.content().strip()) == 0:
 | 
			
		||||
                context.close()
 | 
			
		||||
                browser.close()
 | 
			
		||||
                print("Content Fetcher > Content was empty")
 | 
			
		||||
                raise EmptyReply(url=url, status_code=response.status)
 | 
			
		||||
 | 
			
		||||
            # Run Browser Steps here
 | 
			
		||||
            if self.browser_steps_get_valid_steps():
 | 
			
		||||
                self.iterate_browser_steps()
 | 
			
		||||
                
 | 
			
		||||
            self.page.wait_for_timeout(extra_wait * 1000)
 | 
			
		||||
            self.status_code = response.status
 | 
			
		||||
            self.headers = response.all_headers()
 | 
			
		||||
 | 
			
		||||
            # So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
 | 
			
		||||
            if current_include_filters is not None:
 | 
			
		||||
@@ -532,7 +541,6 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
                "async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}")
 | 
			
		||||
            self.instock_data = self.page.evaluate("async () => {" + self.instock_data_js + "}")
 | 
			
		||||
 | 
			
		||||
            self.content = self.page.content()
 | 
			
		||||
            # Bug 3 in Playwright screenshot handling
 | 
			
		||||
            # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
 | 
			
		||||
            # JPEG is better here because the screenshots can be very very large
 | 
			
		||||
@@ -547,7 +555,7 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                context.close()
 | 
			
		||||
                browser.close()
 | 
			
		||||
                raise ScreenshotUnavailable(url=url, status_code=response.status_code)
 | 
			
		||||
                raise ScreenshotUnavailable(url=url, status_code=None)
 | 
			
		||||
 | 
			
		||||
            context.close()
 | 
			
		||||
            browser.close()
 | 
			
		||||
@@ -606,17 +614,14 @@ class base_html_webdriver(Fetcher):
 | 
			
		||||
            is_binary=False):
 | 
			
		||||
 | 
			
		||||
        from selenium import webdriver
 | 
			
		||||
        from selenium.webdriver.chrome.options import Options as ChromeOptions
 | 
			
		||||
        from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
 | 
			
		||||
        from selenium.common.exceptions import WebDriverException
 | 
			
		||||
        # request_body, request_method unused for now, until some magic in the future happens.
 | 
			
		||||
 | 
			
		||||
        options = ChromeOptions()
 | 
			
		||||
        if self.proxy:
 | 
			
		||||
            options.proxy = self.proxy
 | 
			
		||||
 | 
			
		||||
        self.driver = webdriver.Remote(
 | 
			
		||||
            command_executor=self.command_executor,
 | 
			
		||||
            options=options)
 | 
			
		||||
            desired_capabilities=DesiredCapabilities.CHROME,
 | 
			
		||||
            proxy=self.proxy)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            self.driver.get(url)
 | 
			
		||||
@@ -648,11 +653,11 @@ class base_html_webdriver(Fetcher):
 | 
			
		||||
    # Does the connection to the webdriver work? run a test connection.
 | 
			
		||||
    def is_ready(self):
 | 
			
		||||
        from selenium import webdriver
 | 
			
		||||
        from selenium.webdriver.chrome.options import Options as ChromeOptions
 | 
			
		||||
        from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
 | 
			
		||||
 | 
			
		||||
        self.driver = webdriver.Remote(
 | 
			
		||||
            command_executor=self.command_executor,
 | 
			
		||||
            options=ChromeOptions())
 | 
			
		||||
            desired_capabilities=DesiredCapabilities.CHROME)
 | 
			
		||||
 | 
			
		||||
        # driver.quit() seems to cause better exceptions
 | 
			
		||||
        self.quit()
 | 
			
		||||
 
 | 
			
		||||
@@ -15,20 +15,14 @@ from wtforms import (
 | 
			
		||||
    validators,
 | 
			
		||||
    widgets
 | 
			
		||||
)
 | 
			
		||||
from flask_wtf.file import FileField, FileAllowed
 | 
			
		||||
from wtforms.fields import FieldList
 | 
			
		||||
 | 
			
		||||
from wtforms.validators import ValidationError
 | 
			
		||||
 | 
			
		||||
from validators.url import url as url_validator
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# default
 | 
			
		||||
# each select <option data-enabled="enabled-0-0"
 | 
			
		||||
from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config
 | 
			
		||||
 | 
			
		||||
from changedetectionio import content_fetcher, html_tools
 | 
			
		||||
 | 
			
		||||
from changedetectionio import content_fetcher
 | 
			
		||||
from changedetectionio.notification import (
 | 
			
		||||
    valid_notification_formats,
 | 
			
		||||
)
 | 
			
		||||
@@ -46,7 +40,7 @@ valid_method = {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
default_method = 'GET'
 | 
			
		||||
allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StringListField(StringField):
 | 
			
		||||
    widget = widgets.TextArea()
 | 
			
		||||
@@ -266,23 +260,19 @@ class validateURL(object):
 | 
			
		||||
        self.message = message
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form, field):
 | 
			
		||||
        # This should raise a ValidationError() or not
 | 
			
		||||
        validate_url(field.data)
 | 
			
		||||
        import validators
 | 
			
		||||
        # If hosts that only contain alphanumerics are allowed ("localhost" for example)
 | 
			
		||||
        allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
 | 
			
		||||
        try:
 | 
			
		||||
            validators.url(field.data.strip(), simple_host=allow_simplehost)
 | 
			
		||||
        except validators.ValidationFailure:
 | 
			
		||||
            message = field.gettext('\'%s\' is not a valid URL.' % (field.data.strip()))
 | 
			
		||||
            raise ValidationError(message)
 | 
			
		||||
 | 
			
		||||
def validate_url(test_url):
 | 
			
		||||
    # If hosts that only contain alphanumerics are allowed ("localhost" for example)
 | 
			
		||||
    try:
 | 
			
		||||
        url_validator(test_url, simple_host=allow_simplehost)
 | 
			
		||||
    except validators.ValidationError:
 | 
			
		||||
        #@todo check for xss
 | 
			
		||||
        message = f"'{test_url}' is not a valid URL."
 | 
			
		||||
        # This should be wtforms.validators.
 | 
			
		||||
        raise ValidationError(message)
 | 
			
		||||
        from .model.Watch import is_safe_url
 | 
			
		||||
        if not is_safe_url(field.data):
 | 
			
		||||
            raise ValidationError('Watch protocol is not permitted by SAFE_PROTOCOL_REGEX')
 | 
			
		||||
 | 
			
		||||
    from .model.Watch import is_safe_url
 | 
			
		||||
    if not is_safe_url(test_url):
 | 
			
		||||
        # This should be wtforms.validators.
 | 
			
		||||
        raise ValidationError('Watch protocol is not permitted by SAFE_PROTOCOL_REGEX or incorrect URL format')
 | 
			
		||||
 | 
			
		||||
class ValidateListRegex(object):
 | 
			
		||||
    """
 | 
			
		||||
@@ -294,10 +284,11 @@ class ValidateListRegex(object):
 | 
			
		||||
    def __call__(self, form, field):
 | 
			
		||||
 | 
			
		||||
        for line in field.data:
 | 
			
		||||
            if re.search(html_tools.PERL_STYLE_REGEX, line, re.IGNORECASE):
 | 
			
		||||
            if line[0] == '/' and line[-1] == '/':
 | 
			
		||||
                # Because internally we dont wrap in /
 | 
			
		||||
                line = line.strip('/')
 | 
			
		||||
                try:
 | 
			
		||||
                    regex = html_tools.perl_style_slash_enclosed_regex_to_options(line)
 | 
			
		||||
                    re.compile(regex)
 | 
			
		||||
                    re.compile(line)
 | 
			
		||||
                except re.error:
 | 
			
		||||
                    message = field.gettext('RegEx \'%s\' is not a valid regular expression.')
 | 
			
		||||
                    raise ValidationError(message % (line))
 | 
			
		||||
@@ -407,9 +398,6 @@ class importForm(Form):
 | 
			
		||||
    from . import processors
 | 
			
		||||
    processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
 | 
			
		||||
    urls = TextAreaField('URLs')
 | 
			
		||||
    xlsx_file = FileField('Upload .xlsx file', validators=[FileAllowed(['xlsx'], 'Must be .xlsx file!')])
 | 
			
		||||
    file_mapping = SelectField('File mapping', [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SingleBrowserStep(Form):
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,9 @@
 | 
			
		||||
 | 
			
		||||
from bs4 import BeautifulSoup
 | 
			
		||||
from inscriptis import get_text
 | 
			
		||||
from inscriptis.model.config import ParserConfig
 | 
			
		||||
from jsonpath_ng.ext import parse
 | 
			
		||||
from typing import List
 | 
			
		||||
from inscriptis.css_profiles import CSS_PROFILES, HtmlElement
 | 
			
		||||
from inscriptis.html_properties import Display
 | 
			
		||||
from inscriptis.model.config import ParserConfig
 | 
			
		||||
from xml.sax.saxutils import escape as xml_escape
 | 
			
		||||
import json
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
@@ -71,15 +68,10 @@ def element_removal(selectors: List[str], html_content):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Return str Utf-8 of matched rules
 | 
			
		||||
def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False, is_rss=False):
 | 
			
		||||
def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False):
 | 
			
		||||
    from lxml import etree, html
 | 
			
		||||
 | 
			
		||||
    parser = None
 | 
			
		||||
    if is_rss:
 | 
			
		||||
        # So that we can keep CDATA for cdata_in_document_to_text() to process
 | 
			
		||||
        parser = etree.XMLParser(strip_cdata=False)
 | 
			
		||||
 | 
			
		||||
    tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser)
 | 
			
		||||
    tree = html.fromstring(bytes(html_content, encoding='utf-8'))
 | 
			
		||||
    html_block = ""
 | 
			
		||||
 | 
			
		||||
    r = tree.xpath(xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'})
 | 
			
		||||
@@ -102,6 +94,7 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False
 | 
			
		||||
 | 
			
		||||
    return html_block
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Extract/find element
 | 
			
		||||
def extract_element(find='title', html_content=''):
 | 
			
		||||
 | 
			
		||||
@@ -267,15 +260,8 @@ def strip_ignore_text(content, wordlist, mode="content"):
 | 
			
		||||
 | 
			
		||||
    return "\n".encode('utf8').join(output)
 | 
			
		||||
 | 
			
		||||
def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str:
 | 
			
		||||
    pattern = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\s*)*)\]\]>'
 | 
			
		||||
    def repl(m):
 | 
			
		||||
        text = m.group(1)
 | 
			
		||||
        return xml_escape(html_to_text(html_content=text)).strip()
 | 
			
		||||
 | 
			
		||||
    return re.sub(pattern, repl, html_content)
 | 
			
		||||
 | 
			
		||||
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False) -> str:
 | 
			
		||||
def html_to_text(html_content: str, render_anchor_tag_content=False) -> str:
 | 
			
		||||
    """Converts html string to a string with just the text. If ignoring
 | 
			
		||||
    rendering anchor tag content is enable, anchor tag content are also
 | 
			
		||||
    included in the text
 | 
			
		||||
@@ -291,21 +277,16 @@ def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=Fals
 | 
			
		||||
    #  if anchor tag content flag is set to True define a config for
 | 
			
		||||
    #  extracting this content
 | 
			
		||||
    if render_anchor_tag_content:
 | 
			
		||||
 | 
			
		||||
        parser_config = ParserConfig(
 | 
			
		||||
            annotation_rules={"a": ["hyperlink"]},
 | 
			
		||||
            display_links=True
 | 
			
		||||
            annotation_rules={"a": ["hyperlink"]}, display_links=True
 | 
			
		||||
        )
 | 
			
		||||
    # otherwise set config to None/default
 | 
			
		||||
 | 
			
		||||
    # otherwise set config to None
 | 
			
		||||
    else:
 | 
			
		||||
        parser_config = None
 | 
			
		||||
 | 
			
		||||
    # RSS Mode - Inscriptis will treat `title` as something else.
 | 
			
		||||
    # Make it as a regular block display element (//item/title)
 | 
			
		||||
    # This is a bit of a hack - the real way it to use XSLT to convert it to HTML #1874
 | 
			
		||||
    if is_rss:
 | 
			
		||||
        html_content = re.sub(r'<title([\s>])', r'<h1\1', html_content)
 | 
			
		||||
        html_content = re.sub(r'</title>', r'</h1>', html_content)
 | 
			
		||||
 | 
			
		||||
    # get text and annotations via inscriptis
 | 
			
		||||
    text_content = get_text(html_content, config=parser_config)
 | 
			
		||||
 | 
			
		||||
    return text_content
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,6 @@
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
import time
 | 
			
		||||
import validators
 | 
			
		||||
from wtforms import ValidationError
 | 
			
		||||
 | 
			
		||||
from changedetectionio.forms import validate_url
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Importer():
 | 
			
		||||
@@ -15,7 +12,6 @@ class Importer():
 | 
			
		||||
        self.new_uuids = []
 | 
			
		||||
        self.good = 0
 | 
			
		||||
        self.remaining_data = []
 | 
			
		||||
        self.import_profile = None
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def run(self,
 | 
			
		||||
@@ -136,167 +132,3 @@ class import_distill_io_json(Importer):
 | 
			
		||||
                    good += 1
 | 
			
		||||
 | 
			
		||||
        flash("{} Imported from Distill.io in {:.2f}s, {} Skipped.".format(len(self.new_uuids), time.time() - now, len(self.remaining_data)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class import_xlsx_wachete(Importer):
 | 
			
		||||
 | 
			
		||||
    def run(self,
 | 
			
		||||
            data,
 | 
			
		||||
            flash,
 | 
			
		||||
            datastore,
 | 
			
		||||
            ):
 | 
			
		||||
 | 
			
		||||
        good = 0
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        self.new_uuids = []
 | 
			
		||||
 | 
			
		||||
        from openpyxl import load_workbook
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            wb = load_workbook(data)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            # @todo correct except
 | 
			
		||||
            flash("Unable to read export XLSX file, something wrong with the file?", 'error')
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        row_id = 2
 | 
			
		||||
        for row in wb.active.iter_rows(min_row=row_id):
 | 
			
		||||
            try:
 | 
			
		||||
                extras = {}
 | 
			
		||||
                data = {}
 | 
			
		||||
                for cell in row:
 | 
			
		||||
                    if not cell.value:
 | 
			
		||||
                        continue
 | 
			
		||||
                    column_title = wb.active.cell(row=1, column=cell.column).value.strip().lower()
 | 
			
		||||
                    data[column_title] = cell.value
 | 
			
		||||
 | 
			
		||||
                # Forced switch to webdriver/playwright/etc
 | 
			
		||||
                dynamic_wachet = str(data.get('dynamic wachet', '')).strip().lower()  # Convert bool to str to cover all cases
 | 
			
		||||
                # libreoffice and others can have it as =FALSE() =TRUE(), or bool(true)
 | 
			
		||||
                if 'true' in dynamic_wachet or dynamic_wachet == '1':
 | 
			
		||||
                    extras['fetch_backend'] = 'html_webdriver'
 | 
			
		||||
                elif 'false' in dynamic_wachet or dynamic_wachet == '0':
 | 
			
		||||
                    extras['fetch_backend'] = 'html_requests'
 | 
			
		||||
 | 
			
		||||
                if data.get('xpath'):
 | 
			
		||||
                    # @todo split by || ?
 | 
			
		||||
                    extras['include_filters'] = [data.get('xpath')]
 | 
			
		||||
                if data.get('name'):
 | 
			
		||||
                    extras['title'] = data.get('name').strip()
 | 
			
		||||
                if data.get('interval (min)'):
 | 
			
		||||
                    minutes = int(data.get('interval (min)'))
 | 
			
		||||
                    hours, minutes = divmod(minutes, 60)
 | 
			
		||||
                    days, hours = divmod(hours, 24)
 | 
			
		||||
                    weeks, days = divmod(days, 7)
 | 
			
		||||
                    extras['time_between_check'] = {'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': 0}
 | 
			
		||||
 | 
			
		||||
                # At minimum a URL is required.
 | 
			
		||||
                if data.get('url'):
 | 
			
		||||
                    try:
 | 
			
		||||
                        validate_url(data.get('url'))
 | 
			
		||||
                    except ValidationError as e:
 | 
			
		||||
                        print(">> import URL error", data.get('url'), str(e))
 | 
			
		||||
                        flash(f"Error processing row number {row_id}, URL value was incorrect, row was skipped.", 'error')
 | 
			
		||||
                        # Don't bother processing anything else on this row
 | 
			
		||||
                        continue
 | 
			
		||||
 | 
			
		||||
                    new_uuid = datastore.add_watch(url=data['url'].strip(),
 | 
			
		||||
                                                   extras=extras,
 | 
			
		||||
                                                   tag=data.get('folder'),
 | 
			
		||||
                                                   write_to_disk_now=False)
 | 
			
		||||
                    if new_uuid:
 | 
			
		||||
                        # Straight into the queue.
 | 
			
		||||
                        self.new_uuids.append(new_uuid)
 | 
			
		||||
                        good += 1
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                print(e)
 | 
			
		||||
                flash(f"Error processing row number {row_id}, check all cell data types are correct, row was skipped.", 'error')
 | 
			
		||||
            else:
 | 
			
		||||
                row_id += 1
 | 
			
		||||
 | 
			
		||||
        flash(
 | 
			
		||||
            "{} imported from Wachete .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class import_xlsx_custom(Importer):
 | 
			
		||||
 | 
			
		||||
    def run(self,
 | 
			
		||||
            data,
 | 
			
		||||
            flash,
 | 
			
		||||
            datastore,
 | 
			
		||||
            ):
 | 
			
		||||
 | 
			
		||||
        good = 0
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        self.new_uuids = []
 | 
			
		||||
 | 
			
		||||
        from openpyxl import load_workbook
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            wb = load_workbook(data)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            # @todo correct except
 | 
			
		||||
            flash("Unable to read export XLSX file, something wrong with the file?", 'error')
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # @todo cehck atleast 2 rows, same in other method
 | 
			
		||||
        from .forms import validate_url
 | 
			
		||||
        row_i = 1
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            for row in wb.active.iter_rows():
 | 
			
		||||
                url = None
 | 
			
		||||
                tags = None
 | 
			
		||||
                extras = {}
 | 
			
		||||
 | 
			
		||||
                for cell in row:
 | 
			
		||||
                    if not self.import_profile.get(cell.col_idx):
 | 
			
		||||
                        continue
 | 
			
		||||
                    if not cell.value:
 | 
			
		||||
                        continue
 | 
			
		||||
 | 
			
		||||
                    cell_map = self.import_profile.get(cell.col_idx)
 | 
			
		||||
 | 
			
		||||
                    cell_val = str(cell.value).strip()  # could be bool
 | 
			
		||||
 | 
			
		||||
                    if cell_map == 'url':
 | 
			
		||||
                        url = cell.value.strip()
 | 
			
		||||
                        try:
 | 
			
		||||
                            validate_url(url)
 | 
			
		||||
                        except ValidationError as e:
 | 
			
		||||
                            print(">> Import URL error", url, str(e))
 | 
			
		||||
                            flash(f"Error processing row number {row_i}, URL value was incorrect, row was skipped.", 'error')
 | 
			
		||||
                            # Don't bother processing anything else on this row
 | 
			
		||||
                            url = None
 | 
			
		||||
                            break
 | 
			
		||||
                    elif cell_map == 'tag':
 | 
			
		||||
                        tags = cell.value.strip()
 | 
			
		||||
                    elif cell_map == 'include_filters':
 | 
			
		||||
                        # @todo validate?
 | 
			
		||||
                        extras['include_filters'] = [cell.value.strip()]
 | 
			
		||||
                    elif cell_map == 'interval_minutes':
 | 
			
		||||
                        hours, minutes = divmod(int(cell_val), 60)
 | 
			
		||||
                        days, hours = divmod(hours, 24)
 | 
			
		||||
                        weeks, days = divmod(days, 7)
 | 
			
		||||
                        extras['time_between_check'] = {'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': 0}
 | 
			
		||||
                    else:
 | 
			
		||||
                        extras[cell_map] = cell_val
 | 
			
		||||
 | 
			
		||||
                # At minimum a URL is required.
 | 
			
		||||
                if url:
 | 
			
		||||
                    new_uuid = datastore.add_watch(url=url,
 | 
			
		||||
                                                   extras=extras,
 | 
			
		||||
                                                   tag=tags,
 | 
			
		||||
                                                   write_to_disk_now=False)
 | 
			
		||||
                    if new_uuid:
 | 
			
		||||
                        # Straight into the queue.
 | 
			
		||||
                        self.new_uuids.append(new_uuid)
 | 
			
		||||
                        good += 1
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print(e)
 | 
			
		||||
            flash(f"Error processing row number {row_i}, check all cell data types are correct, row was skipped.", 'error')
 | 
			
		||||
        else:
 | 
			
		||||
            row_i += 1
 | 
			
		||||
 | 
			
		||||
        flash(
 | 
			
		||||
            "{} imported from custom .xlsx in {:.2f}s".format(len(self.new_uuids), time.time() - now))
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,6 @@ import os
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
import uuid
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
 | 
			
		||||
# Allowable protocols, protects against javascript: etc
 | 
			
		||||
# file:// is further checked by ALLOW_FILE_URI
 | 
			
		||||
@@ -19,7 +18,6 @@ from changedetectionio.notification import (
 | 
			
		||||
 | 
			
		||||
base_config = {
 | 
			
		||||
    'body': None,
 | 
			
		||||
    'browser_steps_last_error_step': None,
 | 
			
		||||
    'check_unique_lines': False,  # On change-detected, compare against all history if its something new
 | 
			
		||||
    'check_count': 0,
 | 
			
		||||
    'date_created': None,
 | 
			
		||||
@@ -27,7 +25,6 @@ base_config = {
 | 
			
		||||
    'extract_text': [],  # Extract text by regex after filters
 | 
			
		||||
    'extract_title_as_title': False,
 | 
			
		||||
    'fetch_backend': 'system', # plaintext, playwright etc
 | 
			
		||||
    'fetch_time': 0.0,
 | 
			
		||||
    'processor': 'text_json_diff', # could be restock_diff or others from .processors
 | 
			
		||||
    'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
 | 
			
		||||
    'filter_text_added': True,
 | 
			
		||||
@@ -492,13 +489,3 @@ class model(dict):
 | 
			
		||||
        filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
 | 
			
		||||
        with open(filepath, 'wb') as f:
 | 
			
		||||
            f.write(brotli.compress(contents, mode=brotli.MODE_TEXT))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def get_browsersteps_available_screenshots(self):
 | 
			
		||||
        "For knowing which screenshots are available to show the user in BrowserSteps UI"
 | 
			
		||||
        available = []
 | 
			
		||||
        for f in Path(self.watch_data_dir).glob('step_before-*.jpeg'):
 | 
			
		||||
            step_n=re.search(r'step_before-(\d+)', f.name)
 | 
			
		||||
            if step_n:
 | 
			
		||||
                available.append(step_n.group(1))
 | 
			
		||||
        return available
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ from changedetectionio import content_fetcher, html_tools
 | 
			
		||||
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
 | 
			
		||||
from copy import deepcopy
 | 
			
		||||
from . import difference_detection_processor
 | 
			
		||||
from ..html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text
 | 
			
		||||
from ..html_tools import PERL_STYLE_REGEX
 | 
			
		||||
 | 
			
		||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
 | 
			
		||||
 | 
			
		||||
@@ -153,22 +153,13 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
 | 
			
		||||
        is_json = 'application/json' in fetcher.get_all_headers().get('content-type', '').lower()
 | 
			
		||||
        is_html = not is_json
 | 
			
		||||
        is_rss = False
 | 
			
		||||
 | 
			
		||||
        ctype_header = fetcher.get_all_headers().get('content-type', '').lower()
 | 
			
		||||
        # Go into RSS preprocess for converting CDATA/comment to usable text
 | 
			
		||||
        if any(substring in ctype_header for substring in ['application/xml', 'application/rss', 'text/xml']):
 | 
			
		||||
            if '<rss' in fetcher.content[:100].lower():
 | 
			
		||||
                fetcher.content = cdata_in_document_to_text(html_content=fetcher.content)
 | 
			
		||||
                is_rss = True
 | 
			
		||||
 | 
			
		||||
        # source: support, basically treat it as plaintext
 | 
			
		||||
        if is_source:
 | 
			
		||||
            is_html = False
 | 
			
		||||
            is_json = False
 | 
			
		||||
 | 
			
		||||
        inline_pdf = fetcher.get_all_headers().get('content-disposition', '') and '%PDF-1' in fetcher.content[:10]
 | 
			
		||||
        if watch.is_pdf or 'application/pdf' in fetcher.get_all_headers().get('content-type', '').lower() or inline_pdf:
 | 
			
		||||
        if watch.is_pdf or 'application/pdf' in fetcher.get_all_headers().get('content-type', '').lower():
 | 
			
		||||
            from shutil import which
 | 
			
		||||
            tool = os.getenv("PDF_TO_HTML_TOOL", "pdftohtml")
 | 
			
		||||
            if not which(tool):
 | 
			
		||||
@@ -251,8 +242,7 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
                        if filter_rule[0] == '/' or filter_rule.startswith('xpath:'):
 | 
			
		||||
                            html_content += html_tools.xpath_filter(xpath_filter=filter_rule.replace('xpath:', ''),
 | 
			
		||||
                                                                    html_content=fetcher.content,
 | 
			
		||||
                                                                    append_pretty_line_formatting=not is_source,
 | 
			
		||||
                                                                    is_rss=is_rss)
 | 
			
		||||
                                                                    append_pretty_line_formatting=not is_source)
 | 
			
		||||
                        else:
 | 
			
		||||
                            # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text
 | 
			
		||||
                            html_content += html_tools.include_filters(include_filters=filter_rule,
 | 
			
		||||
@@ -272,9 +262,8 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
                    do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False)
 | 
			
		||||
                    stripped_text_from_html = \
 | 
			
		||||
                        html_tools.html_to_text(
 | 
			
		||||
                            html_content=html_content,
 | 
			
		||||
                            render_anchor_tag_content=do_anchor,
 | 
			
		||||
                            is_rss=is_rss # #1874 activate the <title workaround hack
 | 
			
		||||
                            html_content,
 | 
			
		||||
                            render_anchor_tag_content=do_anchor
 | 
			
		||||
                        )
 | 
			
		||||
 | 
			
		||||
        # Re #340 - return the content before the 'ignore text' was applied
 | 
			
		||||
 
 | 
			
		||||
@@ -321,14 +321,8 @@ $(document).ready(function () {
 | 
			
		||||
            var s = '<div class="control">' + '<a data-step-index=' + i + ' class="pure-button button-secondary button-green button-xsmall apply" >Apply</a> ';
 | 
			
		||||
            if (i > 0) {
 | 
			
		||||
                // The first step never gets these (Goto-site)
 | 
			
		||||
                s += `<a data-step-index="${i}" class="pure-button button-secondary button-xsmall clear" >Clear</a> ` +
 | 
			
		||||
                    `<a data-step-index="${i}" class="pure-button button-secondary button-red button-xsmall remove" >Remove</a>`;
 | 
			
		||||
 | 
			
		||||
                // if a screenshot is available
 | 
			
		||||
                if (browser_steps_available_screenshots.includes(i.toString())) {
 | 
			
		||||
                    var d = (browser_steps_last_error_step === i+1) ? 'before' : 'after';
 | 
			
		||||
                    s += ` <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> `;
 | 
			
		||||
                }
 | 
			
		||||
                s += '<a data-step-index=' + i + ' class="pure-button button-secondary button-xsmall clear" >Clear</a> ' +
 | 
			
		||||
                    '<a data-step-index=' + i + ' class="pure-button button-secondary button-red button-xsmall remove" >Remove</a>';
 | 
			
		||||
            }
 | 
			
		||||
            s += '</div>';
 | 
			
		||||
            $(this).append(s)
 | 
			
		||||
@@ -443,24 +437,6 @@ $(document).ready(function () {
 | 
			
		||||
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $('ul#browser_steps li .control .show-screenshot').click(function (element) {
 | 
			
		||||
        var step_n = $(event.currentTarget).data('step-index');
 | 
			
		||||
        w = window.open(this.href, "_blank", "width=640,height=480");
 | 
			
		||||
        const t = $(event.currentTarget).data('type');
 | 
			
		||||
 | 
			
		||||
        const url = browser_steps_fetch_screenshot_image_url + `&step_n=${step_n}&type=${t}`;
 | 
			
		||||
        w.document.body.innerHTML = `<!DOCTYPE html>
 | 
			
		||||
            <html lang="en">
 | 
			
		||||
                <body>
 | 
			
		||||
                    <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."/>
 | 
			
		||||
                </body>
 | 
			
		||||
        </html>`;
 | 
			
		||||
        w.document.title = `Browser Step at step ${step_n} from last run.`;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (browser_steps_last_error_step) {
 | 
			
		||||
        $("ul#browser_steps>li:nth-child("+browser_steps_last_error_step+")").addClass("browser-step-with-error");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $("ul#browser_steps select").change(function () {
 | 
			
		||||
        set_greyed_state();
 | 
			
		||||
 
 | 
			
		||||
@@ -2,25 +2,8 @@ $(document).ready(function () {
 | 
			
		||||
    var a = document.getElementById("a");
 | 
			
		||||
    var b = document.getElementById("b");
 | 
			
		||||
    var result = document.getElementById("result");
 | 
			
		||||
    var inputs;
 | 
			
		||||
 | 
			
		||||
    $('#jump-next-diff').click(function () {
 | 
			
		||||
 | 
			
		||||
        var element = inputs[inputs.current];
 | 
			
		||||
        var headerOffset = 80;
 | 
			
		||||
        var elementPosition = element.getBoundingClientRect().top;
 | 
			
		||||
        var offsetPosition = elementPosition - headerOffset + window.scrollY;
 | 
			
		||||
 | 
			
		||||
        window.scrollTo({
 | 
			
		||||
            top: offsetPosition,
 | 
			
		||||
            behavior: "smooth",
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        inputs.current++;
 | 
			
		||||
        if (inputs.current >= inputs.length) {
 | 
			
		||||
            inputs.current = 0;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    var inputs = document.getElementsByClassName("change");
 | 
			
		||||
    inputs.current = 0;
 | 
			
		||||
 | 
			
		||||
    function changed() {
 | 
			
		||||
        // https://github.com/kpdecker/jsdiff/issues/389
 | 
			
		||||
@@ -58,6 +41,9 @@ $(document).ready(function () {
 | 
			
		||||
        result.textContent = "";
 | 
			
		||||
        result.appendChild(fragment);
 | 
			
		||||
 | 
			
		||||
        // Jump at start
 | 
			
		||||
        inputs.current = 0;
 | 
			
		||||
 | 
			
		||||
        // For nice mouse-over hover/title information
 | 
			
		||||
        const removed_current_option = $('#diff-version option:selected')
 | 
			
		||||
        if (removed_current_option) {
 | 
			
		||||
@@ -71,12 +57,8 @@ $(document).ready(function () {
 | 
			
		||||
                $(this).prop('title', 'Inserted '+inserted_current_option[0].label);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        // Set the list of possible differences to jump to
 | 
			
		||||
        inputs = document.querySelectorAll('#diff-ui .change')
 | 
			
		||||
        // Set the "current" diff pointer
 | 
			
		||||
        inputs.current = 0;
 | 
			
		||||
        // Goto diff
 | 
			
		||||
        $('#jump-next-diff').click();
 | 
			
		||||
 | 
			
		||||
        next_diff();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $('.needs-localtime').each(function () {
 | 
			
		||||
@@ -116,5 +98,23 @@ $(document).ready(function () {
 | 
			
		||||
        changed();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    function next_diff() {
 | 
			
		||||
        var element = inputs[inputs.current];
 | 
			
		||||
        var headerOffset = 80;
 | 
			
		||||
        var elementPosition = element.getBoundingClientRect().top;
 | 
			
		||||
        var offsetPosition = elementPosition - headerOffset + window.scrollY;
 | 
			
		||||
 | 
			
		||||
        window.scrollTo({
 | 
			
		||||
            top: offsetPosition,
 | 
			
		||||
            behavior: "smooth",
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        inputs.current++;
 | 
			
		||||
        if (inputs.current >= inputs.length) {
 | 
			
		||||
            inputs.current = 0;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -44,9 +44,4 @@ $(document).ready(function () {
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  $('#heart-us').click(function () {
 | 
			
		||||
    $("#overlay").toggleClass('visible');
 | 
			
		||||
    heartpath.style.fill = document.getElementById("overlay").classList.contains("visible") ? '#ff0000' : 'var(--color-background)';
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -4,14 +4,6 @@ $(function () {
 | 
			
		||||
        $(this).closest('.unviewed').removeClass('unviewed');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $('td[data-timestamp]').each(function () {
 | 
			
		||||
        $(this).prop('title', new Intl.DateTimeFormat(undefined,
 | 
			
		||||
            {
 | 
			
		||||
                dateStyle: 'full',
 | 
			
		||||
                timeStyle: 'long'
 | 
			
		||||
            }).format($(this).data('timestamp') * 1000));
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    $("#checkbox-assign-tag").click(function (e) {
 | 
			
		||||
        $('#op_extradata').val(prompt("Enter a tag name"));
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -6,10 +6,6 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  li {
 | 
			
		||||
    &.browser-step-with-error {
 | 
			
		||||
      background-color: #ffd6d6;
 | 
			
		||||
      border-radius: 4px;
 | 
			
		||||
    }
 | 
			
		||||
    &:not(:first-child) {
 | 
			
		||||
      &:hover {
 | 
			
		||||
        opacity: 1.0;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
 | 
			
		||||
#toggle-light-mode {
 | 
			
		||||
/*  width: 3rem;*/
 | 
			
		||||
  width: 3rem;
 | 
			
		||||
  /* default */
 | 
			
		||||
  .icon-dark {
 | 
			
		||||
    display: none;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,38 +0,0 @@
 | 
			
		||||
#overlay {
 | 
			
		||||
 | 
			
		||||
  opacity: 0.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;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  &.visible {
 | 
			
		||||
    transform: translateX(-100%);
 | 
			
		||||
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .content {
 | 
			
		||||
    font-size: 0.875rem;
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
    margin-top: 5rem;
 | 
			
		||||
    max-width: 400px;
 | 
			
		||||
    color: var(--color-watch-table-row-text);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#heartpath {
 | 
			
		||||
  &:hover {
 | 
			
		||||
      fill: #ff0000 !important;
 | 
			
		||||
      transition: all ease 0.3s !important;
 | 
			
		||||
  }
 | 
			
		||||
    transition: all ease 0.3s !important;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,25 +0,0 @@
 | 
			
		||||
.pure-menu-link {
 | 
			
		||||
  padding: 0.5rem 1em;
 | 
			
		||||
  line-height: 1.2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pure-menu-item {
 | 
			
		||||
  svg {
 | 
			
		||||
    height: 1.2rem;
 | 
			
		||||
  }
 | 
			
		||||
  * {
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
  }
 | 
			
		||||
  .github-link {
 | 
			
		||||
    height: 1.8rem;
 | 
			
		||||
    display: block;
 | 
			
		||||
    svg {
 | 
			
		||||
      height: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  .bi-heart {
 | 
			
		||||
    &:hover {
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
 | 
			
		||||
#selector-wrapper {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  max-height: 70vh;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  //width: 100%;
 | 
			
		||||
  >img {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    z-index: 4;
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  >canvas {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    z-index: 5;
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#selector-current-xpath {
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
}
 | 
			
		||||
@@ -9,8 +9,6 @@
 | 
			
		||||
@import "parts/_spinners";
 | 
			
		||||
@import "parts/_variables";
 | 
			
		||||
@import "parts/_darkmode";
 | 
			
		||||
@import "parts/_menu";
 | 
			
		||||
@import "parts/_love";
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
@@ -57,6 +55,11 @@ a.github-link {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#toggle-search {
 | 
			
		||||
  width: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#search-q {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  -webkit-transition: all .9s ease;
 | 
			
		||||
@@ -940,7 +943,32 @@ ul {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@import "parts/_visualselector";
 | 
			
		||||
#selector-wrapper {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  //width: 100%;
 | 
			
		||||
  >img {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    z-index: 4;
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  >canvas {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    z-index: 5;
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#selector-current-xpath {
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#webdriver-override-options {
 | 
			
		||||
  input[type="number"] {
 | 
			
		||||
@@ -1079,4 +1107,3 @@ ul {
 | 
			
		||||
  border-radius: 3px;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -26,9 +26,6 @@
 | 
			
		||||
  #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.0; }
 | 
			
		||||
    #browser_steps li .control {
 | 
			
		||||
@@ -331,7 +328,7 @@ html[data-darkmode="true"] {
 | 
			
		||||
      color: var(--color-watch-table-error); }
 | 
			
		||||
 | 
			
		||||
#toggle-light-mode {
 | 
			
		||||
  /*  width: 3rem;*/
 | 
			
		||||
  width: 3rem;
 | 
			
		||||
  /* default */ }
 | 
			
		||||
  #toggle-light-mode .icon-dark {
 | 
			
		||||
    display: none; }
 | 
			
		||||
@@ -342,52 +339,6 @@ html[data-darkmode="true"] #toggle-light-mode .icon-light {
 | 
			
		||||
html[data-darkmode="true"] #toggle-light-mode .icon-dark {
 | 
			
		||||
  display: block; }
 | 
			
		||||
 | 
			
		||||
.pure-menu-link {
 | 
			
		||||
  padding: 0.5rem 1em;
 | 
			
		||||
  line-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; }
 | 
			
		||||
 | 
			
		||||
#overlay {
 | 
			
		||||
  opacity: 0.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: 0.875rem;
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
    margin-top: 5rem;
 | 
			
		||||
    max-width: 400px;
 | 
			
		||||
    color: var(--color-watch-table-row-text); }
 | 
			
		||||
 | 
			
		||||
#heartpath {
 | 
			
		||||
  transition: all ease 0.3s !important; }
 | 
			
		||||
  #heartpath:hover {
 | 
			
		||||
    fill: #ff0000 !important;
 | 
			
		||||
    transition: all ease 0.3s !important; }
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
  background: var(--color-background-page); }
 | 
			
		||||
@@ -422,6 +373,9 @@ a.github-link {
 | 
			
		||||
  a.github-link:hover {
 | 
			
		||||
    color: var(--color-icon-github-hover); }
 | 
			
		||||
 | 
			
		||||
#toggle-search {
 | 
			
		||||
  width: 2rem; }
 | 
			
		||||
 | 
			
		||||
#search-q {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  -webkit-transition: all .9s ease;
 | 
			
		||||
@@ -1026,7 +980,6 @@ ul {
 | 
			
		||||
 | 
			
		||||
#selector-wrapper {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  max-height: 70vh;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  position: relative; }
 | 
			
		||||
  #selector-wrapper > img {
 | 
			
		||||
 
 | 
			
		||||
@@ -244,16 +244,12 @@ class ChangeDetectionStore:
 | 
			
		||||
        import pathlib
 | 
			
		||||
 | 
			
		||||
        self.__data['watching'][uuid].update({
 | 
			
		||||
                'browser_steps_last_error_step' : None,
 | 
			
		||||
                'check_count': 0,
 | 
			
		||||
                'fetch_time' : 0.0,
 | 
			
		||||
                'has_ldjson_price_data': None,
 | 
			
		||||
                'last_checked': 0,
 | 
			
		||||
                'has_ldjson_price_data': None,
 | 
			
		||||
                'last_error': False,
 | 
			
		||||
                'last_notification_error': False,
 | 
			
		||||
                'last_viewed': 0,
 | 
			
		||||
                'previous_md5': False,
 | 
			
		||||
                'previous_md5_before_filters': False,
 | 
			
		||||
                'track_ldjson_price_data': None,
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
@@ -360,8 +356,6 @@ class ChangeDetectionStore:
 | 
			
		||||
        if write_to_disk_now:
 | 
			
		||||
            self.sync_to_json()
 | 
			
		||||
 | 
			
		||||
        print("added ", url)
 | 
			
		||||
 | 
			
		||||
        return new_uuid
 | 
			
		||||
 | 
			
		||||
    def visualselector_data_is_ready(self, watch_uuid):
 | 
			
		||||
 
 | 
			
		||||
@@ -85,7 +85,6 @@
 | 
			
		||||
              <a href="{{url_for('logout')}}" class="pure-menu-link">LOG OUT</a>
 | 
			
		||||
            </li>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          {% if current_user.is_authenticated or not has_password %}
 | 
			
		||||
          <li class="pure-menu-item pure-form" id="search-menu-item">
 | 
			
		||||
            <!-- We use GET here so it offers people a chance to set bookmarks etc -->
 | 
			
		||||
            <form name="searchForm" action="" method="GET">
 | 
			
		||||
@@ -96,7 +95,6 @@
 | 
			
		||||
              </button>
 | 
			
		||||
            </form>
 | 
			
		||||
          </li>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          <li class="pure-menu-item">
 | 
			
		||||
            <button class="toggle-button" id ="toggle-light-mode" type="button" title="Toggle Light/Dark Mode">
 | 
			
		||||
              <span class="visually-hidden">Toggle light/dark mode</span>
 | 
			
		||||
@@ -108,20 +106,6 @@
 | 
			
		||||
              </span>
 | 
			
		||||
            </button>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="pure-menu-item" id="heart-us">
 | 
			
		||||
                <svg
 | 
			
		||||
                   fill="#ff0000"
 | 
			
		||||
                   class="bi bi-heart"
 | 
			
		||||
                   preserveAspectRatio="xMidYMid meet"
 | 
			
		||||
                   viewBox="0 0 16.9 16.1"
 | 
			
		||||
                   id="svg-heart"
 | 
			
		||||
                   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
                   xmlns:svg="http://www.w3.org/2000/svg">
 | 
			
		||||
                  <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" />
 | 
			
		||||
                </svg>
 | 
			
		||||
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="pure-menu-item">
 | 
			
		||||
            <a class="github-link" href="https://github.com/dgtlmoon/changedetection.io">
 | 
			
		||||
              {% include "svgs/github.svg" %}
 | 
			
		||||
@@ -145,44 +129,7 @@
 | 
			
		||||
      <div class="sticky-tab" id="right-sticky">{{ right_sticky }}</div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <section class="content">
 | 
			
		||||
        <div id="overlay">
 | 
			
		||||
            <div class="content">
 | 
			
		||||
                <strong>changedetection.io needs your support!</strong><br>
 | 
			
		||||
                <p>
 | 
			
		||||
                    You can help us by supporting changedetection.io on these platforms;
 | 
			
		||||
                </p>
 | 
			
		||||
                <p>
 | 
			
		||||
                <ul>
 | 
			
		||||
                    <li>
 | 
			
		||||
                        <a href="https://alternativeto.net/software/changedetection-io/about/">Rate us at
 | 
			
		||||
                        AlternativeTo.net</a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a href="https://github.com/dgtlmoon/changedetection.io">Star us on GitHub</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a href="https://twitter.com/change_det_io">Follow us at Twitter/X</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a href="https://www.linkedin.com/company/changedetection-io">Check us out on LinkedIn</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    And tell your friends and colleagues :)
 | 
			
		||||
                </li>
 | 
			
		||||
                </ul>
 | 
			
		||||
                </p>
 | 
			
		||||
                <p>
 | 
			
		||||
                    The more popular changedetection.io is, the more time we can dedicate to adding amazing features!
 | 
			
		||||
                </p>
 | 
			
		||||
                <p>
 | 
			
		||||
                    Many thanks :)<br>
 | 
			
		||||
                </p>
 | 
			
		||||
                <p>
 | 
			
		||||
                    <i>changedetection.io team</i>
 | 
			
		||||
                </p>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <header>
 | 
			
		||||
      <header>
 | 
			
		||||
        {% block header %}{% endblock %}
 | 
			
		||||
      </header>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -60,7 +60,7 @@
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div id="diff-jump">
 | 
			
		||||
    <a id="jump-next-diff" title="Jump to next difference">Jump</a>
 | 
			
		||||
    <a onclick="next_diff();">Jump</a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
 | 
			
		||||
 
 | 
			
		||||
@@ -4,10 +4,8 @@
 | 
			
		||||
{% from '_common_fields.jinja' import render_common_settings_form %}
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
 | 
			
		||||
<script>
 | 
			
		||||
    const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
 | 
			
		||||
 | 
			
		||||
    const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
 | 
			
		||||
    const browser_steps_fetch_screenshot_image_url="{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid)}}";
 | 
			
		||||
    const browser_steps_last_error_step={{ watch.browser_steps_last_error_step|tojson }};
 | 
			
		||||
    const browser_steps_start_url="{{url_for('browser_steps.browsersteps_start_session', uuid=uuid)}}";
 | 
			
		||||
    const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}";
 | 
			
		||||
{% if emailprefix %}
 | 
			
		||||
@@ -51,7 +49,6 @@
 | 
			
		||||
            <li class="tab"><a href="#restock">Restock Detection</a></li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            <li class="tab"><a href="#notifications">Notifications</a></li>
 | 
			
		||||
            <li class="tab"><a href="#stats">Stats</a></li>
 | 
			
		||||
        </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
@@ -112,7 +109,7 @@
 | 
			
		||||
                        <span class="pure-form-message-inline">
 | 
			
		||||
                            <p>Use the <strong>Basic</strong> method (default) where your watched site doesn't need Javascript to render.</p>
 | 
			
		||||
                            <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>
 | 
			
		||||
                            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>
 | 
			
		||||
                            Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a>
 | 
			
		||||
                        </span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                {% if form.proxy %}
 | 
			
		||||
@@ -444,35 +441,7 @@ Unavailable") }}
 | 
			
		||||
                </fieldset>
 | 
			
		||||
            </div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            <div class="tab-pane-inner" id="stats">
 | 
			
		||||
                <div class="pure-control-group">
 | 
			
		||||
                    <style>
 | 
			
		||||
                    #stats-table tr > td:first-child {
 | 
			
		||||
                        font-weight: bold;
 | 
			
		||||
                    }
 | 
			
		||||
                    </style>
 | 
			
		||||
                    <table class="pure-table" id="stats-table">
 | 
			
		||||
                        <tbody>
 | 
			
		||||
                        <tr>
 | 
			
		||||
                            <td>Check count</td>
 | 
			
		||||
                            <td>{{ "{:,}".format( watch.check_count) }}</td>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                        <tr>
 | 
			
		||||
                            <td>Consecutive filter failures</td>
 | 
			
		||||
                            <td>{{ "{:,}".format( watch.consecutive_filter_failures) }}</td>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                        <tr>
 | 
			
		||||
                            <td>History length</td>
 | 
			
		||||
                            <td>{{ "{:,}".format(watch.history|length) }}</td>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                        <tr>
 | 
			
		||||
                            <td>Last fetch time</td>
 | 
			
		||||
                            <td>{{ watch.fetch_time }}s</td>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                        </tbody>
 | 
			
		||||
                    </table>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div id="actions">
 | 
			
		||||
                <div class="pure-control-group">
 | 
			
		||||
                    {{ render_button(form.save_button) }}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,12 +8,11 @@
 | 
			
		||||
        <ul>
 | 
			
		||||
            <li class="tab" id=""><a href="#url-list">URL List</a></li>
 | 
			
		||||
            <li class="tab"><a href="#distill-io">Distill.io</a></li>
 | 
			
		||||
            <li class="tab"><a href="#xlsx">.XLSX & Wachete</a></li>
 | 
			
		||||
        </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="box-wrap inner">
 | 
			
		||||
        <form class="pure-form" action="{{url_for('import_page')}}" method="POST" enctype="multipart/form-data">
 | 
			
		||||
        <form class="pure-form pure-form-aligned" action="{{url_for('import_page')}}" method="POST">
 | 
			
		||||
            <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
            <div class="tab-pane-inner" id="url-list">
 | 
			
		||||
                    <legend>
 | 
			
		||||
@@ -80,42 +79,6 @@
 | 
			
		||||
" rows="25">{{ original_distill_json }}</textarea>
 | 
			
		||||
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="tab-pane-inner" id="xlsx">
 | 
			
		||||
            <fieldset>
 | 
			
		||||
                <div class="pure-control-group">
 | 
			
		||||
                {{ render_field(form.xlsx_file, class="processor") }}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="pure-control-group">
 | 
			
		||||
                    {{ render_field(form.file_mapping, class="processor") }}
 | 
			
		||||
                </div>
 | 
			
		||||
            </fieldset>
 | 
			
		||||
                <div class="pure-control-group">
 | 
			
		||||
                <span class="pure-form-message-inline">
 | 
			
		||||
                    Table of custom column and data types mapping for the <strong>Custom mapping</strong> File mapping type.
 | 
			
		||||
                </span>
 | 
			
		||||
                    <table style="border: 1px solid #aaa; padding: 0.5rem; border-radius: 4px;">
 | 
			
		||||
                        <tr>
 | 
			
		||||
                            <td><strong>Column #</strong></td>
 | 
			
		||||
                            {% for n in range(4) %}
 | 
			
		||||
                                <td><input type="number" name="custom_xlsx[col_{{n}}]" style="width: 4rem;" min="1"></td>
 | 
			
		||||
                            {%  endfor %}
 | 
			
		||||
                        </tr>
 | 
			
		||||
                        <tr>
 | 
			
		||||
                            <td><strong>Type</strong></td>
 | 
			
		||||
                            {% for n in range(4) %}
 | 
			
		||||
                                <td><select name="custom_xlsx[col_type_{{n}}]">
 | 
			
		||||
                                    <option value="" style="color: #aaa"> -- none --</option>
 | 
			
		||||
                                    <option value="url">URL</option>
 | 
			
		||||
                                    <option value="title">Title</option>
 | 
			
		||||
                                    <option value="include_filter">CSS/xPath filter</option>
 | 
			
		||||
                                    <option value="tag">Group / Tag name(s)</option>
 | 
			
		||||
                                    <option value="interval_minutes">Recheck time (minutes)</option>
 | 
			
		||||
                                </select></td>
 | 
			
		||||
                            {%  endfor %}
 | 
			
		||||
                        </tr>
 | 
			
		||||
                    </table>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <button type="submit" class="pure-button pure-input-1-2 pure-button-primary">Import</button>
 | 
			
		||||
        </form>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -109,7 +109,7 @@
 | 
			
		||||
                        <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>
 | 
			
		||||
                    </span>
 | 
			
		||||
                    <br>
 | 
			
		||||
                    Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a>
 | 
			
		||||
                    Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using BrightData Proxies, find out more here.</a>
 | 
			
		||||
                </div>
 | 
			
		||||
                <fieldset class="pure-group" id="webdriver-override-options">
 | 
			
		||||
                    <div class="pure-form-message-inline">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,3 @@
 | 
			
		||||
<svg class="octicon octicon-mark-github v-align-middle"  viewbox="0 0 16 16" version="1.1" aria-hidden="true">
 | 
			
		||||
    <path
 | 
			
		||||
     fill-rule="evenodd"
 | 
			
		||||
     d="M 8,0 C 3.58,0 0,3.58 0,8 c 0,3.54 2.29,6.53 5.47,7.59 0.4,0.07 0.55,-0.17 0.55,-0.38 0,-0.19 -0.01,-0.82 -0.01,-1.49 C 4,14.09 3.48,13.23 3.32,12.78 3.23,12.55 2.84,11.84 2.5,11.65 2.22,11.5 1.82,11.13 2.49,11.12 3.12,11.11 3.57,11.7 3.72,11.94 4.44,13.15 5.59,12.81 6.05,12.6 6.12,12.08 6.33,11.73 6.56,11.53 4.78,11.33 2.92,10.64 2.92,7.58 2.92,6.71 3.23,5.99 3.74,5.43 3.66,5.23 3.38,4.41 3.82,3.31 c 0,0 0.67,-0.21 2.2,0.82 0.64,-0.18 1.32,-0.27 2,-0.27 0.68,0 1.36,0.09 2,0.27 1.53,-1.04 2.2,-0.82 2.2,-0.82 0.44,1.1 0.16,1.92 0.08,2.12 0.51,0.56 0.82,1.27 0.82,2.15 0,3.07 -1.87,3.75 -3.65,3.95 0.29,0.25 0.54,0.73 0.54,1.48 0,1.07 -0.01,1.93 -0.01,2.2 0,0.21 0.15,0.46 0.55,0.38 A 8.013,8.013 0 0 0 16,8 C 16,3.58 12.42,0 8,0 Z"
 | 
			
		||||
     id="path2" />
 | 
			
		||||
<svg class="octicon octicon-mark-github v-align-middle" height="32" viewbox="0 0 16 16" version="1.1" width="32" aria-hidden="true">
 | 
			
		||||
  <path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
 | 
			
		||||
</svg>
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 749 B  | 
@@ -154,8 +154,8 @@
 | 
			
		||||
                    {% endfor %}
 | 
			
		||||
 | 
			
		||||
                </td>
 | 
			
		||||
                <td class="last-checked" data-timestamp="{{ watch.last_checked }}">{{watch|format_last_checked_time|safe}}</td>
 | 
			
		||||
                <td class="last-changed" data-timestamp="{{ watch.last_changed }}">{% if watch.history_n >=2 and watch.last_changed >0 %}
 | 
			
		||||
                <td class="last-checked">{{watch|format_last_checked_time|safe}}</td>
 | 
			
		||||
                <td class="last-changed">{% if watch.history_n >=2 and watch.last_changed >0 %}
 | 
			
		||||
                    {{watch.last_changed|format_timestamp_timeago}}
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                    Not yet
 | 
			
		||||
@@ -178,18 +178,13 @@
 | 
			
		||||
            </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
        <ul id="post-list-buttons">
 | 
			
		||||
            {% if errored_count %}
 | 
			
		||||
            <li>
 | 
			
		||||
                <a href="{{url_for('index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error ">With errors ({{ errored_count }})</a>
 | 
			
		||||
            </li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if has_unviewed %}
 | 
			
		||||
            <li>
 | 
			
		||||
                <a href="{{url_for('mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Mark all viewed</a>
 | 
			
		||||
                <a href="{{url_for('mark_all_viewed', tag=request.args.get('tag')) }}" class="pure-button button-tag ">Mark all viewed</a>
 | 
			
		||||
            </li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            <li>
 | 
			
		||||
               <a href="{{ url_for('form_watch_checknow', tag=active_tag, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Recheck
 | 
			
		||||
               <a href="{{ url_for('form_watch_checknow', tag=active_tag) }}" class="pure-button button-tag ">Recheck
 | 
			
		||||
                all {% if active_tag%} in "{{tags[active_tag].title}}"{%endif%}</a>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li>
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@@ -1,4 +1,4 @@
 | 
			
		||||
from .util import live_server_setup, extract_UUID_from_client, wait_for_all_checks
 | 
			
		||||
from . util import live_server_setup, extract_UUID_from_client
 | 
			
		||||
from flask import url_for
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
@@ -19,16 +19,10 @@ def test_check_access_control(app, client, live_server):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        assert b"1 Imported" in res.data
 | 
			
		||||
        time.sleep(3)
 | 
			
		||||
        # causes a 'Popped wrong request context.' error when client. is accessed?
 | 
			
		||||
        #wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
        res = c.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
        time.sleep(2)
 | 
			
		||||
        res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
        assert b'1 watches queued for rechecking.' in res.data
 | 
			
		||||
        time.sleep(3)
 | 
			
		||||
        # causes a 'Popped wrong request context.' error when client. is accessed?
 | 
			
		||||
        #wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
        time.sleep(2)
 | 
			
		||||
 | 
			
		||||
        # Enable password check and diff page access bypass
 | 
			
		||||
        res = c.post(
 | 
			
		||||
@@ -48,7 +42,7 @@ def test_check_access_control(app, client, live_server):
 | 
			
		||||
        assert b"Login" in res.data
 | 
			
		||||
 | 
			
		||||
        # The diff page should return something valid when logged out
 | 
			
		||||
        res = c.get(url_for("diff_history_page", uuid="first"))
 | 
			
		||||
        res = client.get(url_for("diff_history_page", uuid="first"))
 | 
			
		||||
        assert b'Random content' in res.data
 | 
			
		||||
 | 
			
		||||
        # Check wrong password does not let us in
 | 
			
		||||
@@ -89,8 +83,6 @@ def test_check_access_control(app, client, live_server):
 | 
			
		||||
        res = c.get(url_for("logout"),
 | 
			
		||||
            follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
        assert b"Login" in res.data
 | 
			
		||||
 | 
			
		||||
        res = c.get(url_for("settings_page"),
 | 
			
		||||
            follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
@@ -168,5 +160,5 @@ def test_check_access_control(app, client, live_server):
 | 
			
		||||
        assert b"Login" in res.data
 | 
			
		||||
 | 
			
		||||
        # The diff page should return something valid when logged out
 | 
			
		||||
        res = c.get(url_for("diff_history_page", uuid="first"))
 | 
			
		||||
        res = client.get(url_for("diff_history_page", uuid="first"))
 | 
			
		||||
        assert b'Random content' not in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -202,35 +202,3 @@ def test_check_filter_and_regex_extract(client, live_server):
 | 
			
		||||
 | 
			
		||||
    # Should not be here
 | 
			
		||||
    assert b'Some text that did change' not in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_regex_error_handling(client, live_server):
 | 
			
		||||
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data={"urls": test_url},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    ### test regex error handling
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data={"extract_text": '/something bad\d{3/XYZ',
 | 
			
		||||
              "url": test_url,
 | 
			
		||||
              "fetch_backend": "html_requests"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    with open('/tmp/fuck.html', 'wb') as f:
 | 
			
		||||
        f.write(res.data)
 | 
			
		||||
 | 
			
		||||
    assert b'is not a valid regular expression.' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,16 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
import io
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from .util import live_server_setup
 | 
			
		||||
def test_setup(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
def test_import(client, live_server):
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
@@ -122,97 +119,3 @@ def test_import_distillio(client, live_server):
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    # Clear flask alerts
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
 | 
			
		||||
def test_import_custom_xlsx(client, live_server):
 | 
			
		||||
    """Test can upload a excel spreadsheet and the watches are created correctly"""
 | 
			
		||||
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    dirname = os.path.dirname(__file__)
 | 
			
		||||
    filename = os.path.join(dirname, 'import/spreadsheet.xlsx')
 | 
			
		||||
    with open(filename, 'rb') as f:
 | 
			
		||||
 | 
			
		||||
        data= {
 | 
			
		||||
            'file_mapping': 'custom',
 | 
			
		||||
            'custom_xlsx[col_0]': '1',
 | 
			
		||||
            'custom_xlsx[col_1]': '3',
 | 
			
		||||
            'custom_xlsx[col_2]': '5',
 | 
			
		||||
            'custom_xlsx[col_3]': '4',
 | 
			
		||||
            'custom_xlsx[col_type_0]': 'title',
 | 
			
		||||
            'custom_xlsx[col_type_1]': 'url',
 | 
			
		||||
            'custom_xlsx[col_type_2]': 'include_filters',
 | 
			
		||||
            'custom_xlsx[col_type_3]': 'interval_minutes',
 | 
			
		||||
            'xlsx_file': (io.BytesIO(f.read()), 'spreadsheet.xlsx')
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data=data,
 | 
			
		||||
        follow_redirects=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b'4 imported from custom .xlsx' in res.data
 | 
			
		||||
    # Because this row was actually just a header with no usable URL, we should get an error
 | 
			
		||||
    assert b'Error processing row number 1' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("index")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b'Somesite results ABC' in res.data
 | 
			
		||||
    assert b'City news results' in res.data
 | 
			
		||||
 | 
			
		||||
    # Just find one to check over
 | 
			
		||||
    for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items():
 | 
			
		||||
        if watch.get('title') == 'Somesite results ABC':
 | 
			
		||||
            filters = watch.get('include_filters')
 | 
			
		||||
            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]'
 | 
			
		||||
            assert watch.get('time_between_check') == {'weeks': 0, 'days': 1, 'hours': 6, 'minutes': 24, 'seconds': 0}
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 | 
			
		||||
def test_import_watchete_xlsx(client, live_server):
 | 
			
		||||
    """Test can upload a excel spreadsheet and the watches are created correctly"""
 | 
			
		||||
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    dirname = os.path.dirname(__file__)
 | 
			
		||||
    filename = os.path.join(dirname, 'import/spreadsheet.xlsx')
 | 
			
		||||
    with open(filename, 'rb') as f:
 | 
			
		||||
 | 
			
		||||
        data= {
 | 
			
		||||
            'file_mapping': 'wachete',
 | 
			
		||||
            'xlsx_file': (io.BytesIO(f.read()), 'spreadsheet.xlsx')
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data=data,
 | 
			
		||||
        follow_redirects=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b'4 imported from Wachete .xlsx' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("index")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b'Somesite results ABC' in res.data
 | 
			
		||||
    assert b'City news results' in res.data
 | 
			
		||||
 | 
			
		||||
    # Just find one to check over
 | 
			
		||||
    for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items():
 | 
			
		||||
        if watch.get('title') == 'Somesite results ABC':
 | 
			
		||||
            filters = watch.get('include_filters')
 | 
			
		||||
            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]'
 | 
			
		||||
            assert watch.get('time_between_check') == {'weeks': 0, 'days': 1, 'hours': 6, 'minutes': 24, 'seconds': 0}
 | 
			
		||||
            assert watch.get('fetch_backend') == 'html_requests' # Has inactive 'dynamic wachet'
 | 
			
		||||
 | 
			
		||||
        if watch.get('title') == 'JS website':
 | 
			
		||||
            assert watch.get('fetch_backend') == 'html_webdriver' # Has active 'dynamic wachet'
 | 
			
		||||
 | 
			
		||||
        if watch.get('title') == 'system default website':
 | 
			
		||||
            assert watch.get('fetch_backend') == 'system' # uses default if blank
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -2,61 +2,12 @@
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \
 | 
			
		||||
    extract_UUID_from_client
 | 
			
		||||
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_original_cdata_xml():
 | 
			
		||||
    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">
 | 
			
		||||
    <channel>
 | 
			
		||||
    <title>Gizi</title>
 | 
			
		||||
    <link>https://test.com</link>
 | 
			
		||||
    <atom:link href="https://testsite.com" rel="self" type="application/rss+xml"/>
 | 
			
		||||
    <description>
 | 
			
		||||
    <![CDATA[ The Future Could Be Here ]]>
 | 
			
		||||
    </description>
 | 
			
		||||
    <language>en</language>
 | 
			
		||||
    <item>
 | 
			
		||||
    <title>
 | 
			
		||||
    <![CDATA[ <img src="https://testsite.com/hacked.jpg"> Hackers can access your computer ]]>
 | 
			
		||||
    </title>
 | 
			
		||||
    <link>https://testsite.com/news/12341234234</link>
 | 
			
		||||
    <description>
 | 
			
		||||
    <![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> ]]>
 | 
			
		||||
    </description>
 | 
			
		||||
    <category>cybernetics</category>
 | 
			
		||||
    <category>rand corporation</category>
 | 
			
		||||
    <pubDate>Tue, 17 Oct 2023 15:10:00 GMT</pubDate>
 | 
			
		||||
    <guid isPermaLink="false">1850933241</guid>
 | 
			
		||||
    <dc:creator>
 | 
			
		||||
    <![CDATA[ Mr Hacker News ]]>
 | 
			
		||||
    </dc:creator>
 | 
			
		||||
    <media:thumbnail url="https://testsite.com/thumbnail-c224e10d81488e818701c981da04869e.jpg"/>
 | 
			
		||||
    </item>
 | 
			
		||||
 | 
			
		||||
    <item>
 | 
			
		||||
        <title>    Some other title    </title>
 | 
			
		||||
        <link>https://testsite.com/news/12341234236</link>
 | 
			
		||||
        <description>
 | 
			
		||||
        Some other description
 | 
			
		||||
        </description>
 | 
			
		||||
    </item>    
 | 
			
		||||
    </channel>
 | 
			
		||||
    </rss>
 | 
			
		||||
            """
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        f.write(test_return_data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_setup(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
def test_rss_and_token(client, live_server):
 | 
			
		||||
    #    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    rss_token = extract_rss_token_from_UI(client)
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    res = client.post(
 | 
			
		||||
@@ -66,11 +17,11 @@ def test_rss_and_token(client, live_server):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    rss_token = extract_rss_token_from_UI(client)
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    set_modified_response()
 | 
			
		||||
    time.sleep(2)
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(2)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    res = client.get(
 | 
			
		||||
@@ -86,80 +37,3 @@ def test_rss_and_token(client, live_server):
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Access denied, bad token" not in res.data
 | 
			
		||||
    assert b"Random content" in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
def test_basic_cdata_rss_markup(client, live_server):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    set_original_cdata_xml()
 | 
			
		||||
 | 
			
		||||
    test_url = url_for('test_endpoint', content_type="application/xml", _external=True)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data={"urls": test_url},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b'CDATA' not in res.data
 | 
			
		||||
    assert b'<![' not in res.data
 | 
			
		||||
    assert b'Hackers can access your computer' in res.data
 | 
			
		||||
    assert b'The days of Terminator' in res.data
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
def test_rss_xpath_filtering(client, live_server):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    set_original_cdata_xml()
 | 
			
		||||
 | 
			
		||||
    test_url = url_for('test_endpoint', content_type="application/xml", _external=True)
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Watch added in Paused state, saving will unpause" in res.data
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid=uuid, unpause_on_save=1),
 | 
			
		||||
        data={
 | 
			
		||||
                "include_filters": "//item/title",
 | 
			
		||||
                "fetch_backend": "html_requests",
 | 
			
		||||
                "headers": "",
 | 
			
		||||
                "proxy": "no-proxy",
 | 
			
		||||
                "tags": "",
 | 
			
		||||
                "url": test_url,
 | 
			
		||||
              },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"unpaused" in res.data
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b'CDATA' not in res.data
 | 
			
		||||
    assert b'<![' not in res.data
 | 
			
		||||
    # #1874  All but the first <title was getting selected
 | 
			
		||||
    # Convert any HTML with just a top level <title> to <h1> to be sure title renders
 | 
			
		||||
 | 
			
		||||
    assert b'Hackers can access your computer' in res.data # Should ONLY be selected by the xpath
 | 
			
		||||
    assert b'Some other title' in res.data  # Should ONLY be selected by the xpath
 | 
			
		||||
    assert b'The days of Terminator' not in res.data # Should NOT be selected by the xpath
 | 
			
		||||
    assert b'Some other description' not in res.data  # Should NOT be selected by the xpath
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
from . util import live_server_setup
 | 
			
		||||
 | 
			
		||||
from ..html_tools import *
 | 
			
		||||
 | 
			
		||||
@@ -86,14 +86,14 @@ def test_check_xpath_filter_utf8(client, live_server):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'Unicode strings with encoding declaration are not supported.' not in res.data
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
@@ -140,14 +140,14 @@ def test_check_xpath_text_function_utf8(client, live_server):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data={"include_filters": filter, "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'Unicode strings with encoding declaration are not supported.' not in res.data
 | 
			
		||||
 | 
			
		||||
@@ -164,6 +164,7 @@ def test_check_xpath_text_function_utf8(client, live_server):
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 | 
			
		||||
def test_check_markup_xpath_filter_restriction(client, live_server):
 | 
			
		||||
    sleep_time_for_fetch_thread = 3
 | 
			
		||||
 | 
			
		||||
    xpath_filter = "//*[contains(@class, 'sametext')]"
 | 
			
		||||
 | 
			
		||||
@@ -182,7 +183,7 @@ def test_check_markup_xpath_filter_restriction(client, live_server):
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
    # Goto the edit page, add our ignore text
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
@@ -194,7 +195,7 @@ def test_check_markup_xpath_filter_restriction(client, live_server):
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
    # view it/reset state back to viewed
 | 
			
		||||
    client.get(url_for("diff_history_page", uuid="first"), follow_redirects=True)
 | 
			
		||||
@@ -205,7 +206,7 @@ def test_check_markup_xpath_filter_restriction(client, live_server):
 | 
			
		||||
    # Trigger a check
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
@@ -215,6 +216,9 @@ def test_check_markup_xpath_filter_restriction(client, live_server):
 | 
			
		||||
 | 
			
		||||
def test_xpath_validation(client, live_server):
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
@@ -223,7 +227,7 @@ def test_xpath_validation(client, live_server):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(2)
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
@@ -240,8 +244,11 @@ def test_check_with_prefix_include_filters(client, live_server):
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
@@ -250,7 +257,7 @@ def test_check_with_prefix_include_filters(client, live_server):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
@@ -259,7 +266,7 @@ def test_check_with_prefix_include_filters(client, live_server):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
@@ -270,46 +277,3 @@ def test_check_with_prefix_include_filters(client, live_server):
 | 
			
		||||
    assert b"Some text that will change" not in res.data #not in selector
 | 
			
		||||
 | 
			
		||||
    client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
def test_various_rules(client, live_server):
 | 
			
		||||
    # Just check these don't error
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        f.write("""<html>
 | 
			
		||||
       <body>
 | 
			
		||||
     Some initial text<br>
 | 
			
		||||
     <p>Which is across multiple lines</p>
 | 
			
		||||
     <br>
 | 
			
		||||
     So let's see what happens.  <br>
 | 
			
		||||
     <div class="sametext">Some text thats the same</div>
 | 
			
		||||
     <div class="changetext">Some text that will change</div>
 | 
			
		||||
     <a href=''>some linky </a>
 | 
			
		||||
     <a href=''>another some linky </a>
 | 
			
		||||
     <!-- related to https://github.com/dgtlmoon/changedetection.io/pull/1774 -->
 | 
			
		||||
     <input   type="email"   id="email" />
 | 
			
		||||
     </body>
 | 
			
		||||
     </html>
 | 
			
		||||
    """)
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data={"urls": test_url},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    for r in ['//div', '//a', 'xpath://div', 'xpath://a']:
 | 
			
		||||
        res = client.post(
 | 
			
		||||
            url_for("edit_page", uuid="first"),
 | 
			
		||||
            data={"include_filters": r,
 | 
			
		||||
                  "url": test_url,
 | 
			
		||||
                  "tags": "",
 | 
			
		||||
                  "headers": "",
 | 
			
		||||
                  'fetch_backend': "html_requests"},
 | 
			
		||||
            follow_redirects=True
 | 
			
		||||
        )
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
        assert b"Updated watch." in res.data
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        assert b'fetch-error' not in res.data, f"Should not see errors after '{r} filter"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,18 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
import os
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
 | 
			
		||||
 | 
			
		||||
def test_setup(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
# Add a site in paused mode, add an invalid filter, we should still have visual selector data ready
 | 
			
		||||
def test_visual_selector_content_ready(client, live_server):
 | 
			
		||||
    import os
 | 
			
		||||
    import json
 | 
			
		||||
 | 
			
		||||
    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test"
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url
 | 
			
		||||
    test_url = "https://changedetection.io/ci-test/test-runjs.html"
 | 
			
		||||
@@ -54,13 +53,6 @@ def test_visual_selector_content_ready(client, live_server):
 | 
			
		||||
    with open(os.path.join('test-datastore', uuid, 'elements.json'), 'r') as f:
 | 
			
		||||
        json.load(f)
 | 
			
		||||
 | 
			
		||||
    # Attempt to fetch it via the web hook that the browser would use
 | 
			
		||||
    res = client.get(url_for('static_content', group='visual_selector_data', filename=uuid))
 | 
			
		||||
    json.loads(res.data)
 | 
			
		||||
    assert res.mimetype == 'application/json'
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Some options should be enabled
 | 
			
		||||
    # @todo - in the future, the visibility should be toggled by JS from the request type setting
 | 
			
		||||
    res = client.get(
 | 
			
		||||
@@ -68,75 +60,4 @@ def test_visual_selector_content_ready(client, live_server):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b'notification_screenshot' in res.data
 | 
			
		||||
    client.get(
 | 
			
		||||
        url_for("form_delete", uuid="all"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
def test_basic_browserstep(client, live_server):
 | 
			
		||||
 | 
			
		||||
    assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test"
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page, because the docker container (playwright/selenium) wont be able to connect to our usual test url
 | 
			
		||||
    test_url = "https://changedetection.io/ci-test/test-runjs.html"
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Watch added in Paused state, saving will unpause" in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first", unpause_on_save=1),
 | 
			
		||||
        data={
 | 
			
		||||
              "url": test_url,
 | 
			
		||||
              "tags": "",
 | 
			
		||||
              "headers": "",
 | 
			
		||||
              'fetch_backend': "html_webdriver",
 | 
			
		||||
              'browser_steps-0-operation': 'Goto site',
 | 
			
		||||
              'browser_steps-1-operation': 'Click element',
 | 
			
		||||
              'browser_steps-1-selector': 'button[name=test-button]',
 | 
			
		||||
              'browser_steps-1-optional_value': ''
 | 
			
		||||
        },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"unpaused" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
 | 
			
		||||
    # Check HTML conversion detected and workd
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid=uuid),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"This text should be removed" not in res.data
 | 
			
		||||
    assert b"I smell JavaScript because the button was pressed" in res.data
 | 
			
		||||
 | 
			
		||||
    # now test for 404 errors
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid=uuid, unpause_on_save=1),
 | 
			
		||||
        data={
 | 
			
		||||
              "url": "https://changedetection.io/404",
 | 
			
		||||
              "tags": "",
 | 
			
		||||
              "headers": "",
 | 
			
		||||
              'fetch_backend': "html_webdriver",
 | 
			
		||||
              'browser_steps-0-operation': 'Goto site',
 | 
			
		||||
              'browser_steps-1-operation': 'Click element',
 | 
			
		||||
              'browser_steps-1-selector': 'button[name=test-button]',
 | 
			
		||||
              'browser_steps-1-optional_value': ''
 | 
			
		||||
        },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"unpaused" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'Error - 404' in res.data
 | 
			
		||||
 | 
			
		||||
    client.get(
 | 
			
		||||
        url_for("form_delete", uuid="all"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
@@ -238,9 +238,7 @@ class update_worker(threading.Thread):
 | 
			
		||||
                            # Used as a default and also by some tests
 | 
			
		||||
                            update_handler = text_json_diff.perform_site_check(datastore=self.datastore)
 | 
			
		||||
 | 
			
		||||
                        self.datastore.data['watching'][uuid]['browser_steps_last_error_step'] = None
 | 
			
		||||
                        changed_detected, update_obj, contents = update_handler.run(uuid, skip_when_checksum_same=queued_item_data.item.get('skip_when_checksum_same'))
 | 
			
		||||
 | 
			
		||||
                        # Re #342
 | 
			
		||||
                        # In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes.
 | 
			
		||||
                        # We then convert/.decode('utf-8') for the notification etc
 | 
			
		||||
@@ -326,13 +324,8 @@ class update_worker(threading.Thread):
 | 
			
		||||
                        if not self.datastore.data['watching'].get(uuid):
 | 
			
		||||
                            continue
 | 
			
		||||
 | 
			
		||||
                        error_step = e.step_n + 1
 | 
			
		||||
                        err_text = f"Warning, browser step at position {error_step} could not run, target not found, check the watch, add a delay if necessary, view Browser Steps to see screenshot at that step"
 | 
			
		||||
                        self.datastore.update_watch(uuid=uuid,
 | 
			
		||||
                                                    update_obj={'last_error': err_text,
 | 
			
		||||
                                                                'browser_steps_last_error_step': error_step
 | 
			
		||||
                                                                }
 | 
			
		||||
                                                    )
 | 
			
		||||
                        err_text = "Warning, browser step at position {} could not run, target not found, check the watch, add a delay if necessary.".format(e.step_n+1)
 | 
			
		||||
                        self.datastore.update_watch(uuid=uuid, update_obj={'last_error': err_text})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                        if self.datastore.data['watching'][uuid].get('filter_failure_notification_send', False):
 | 
			
		||||
 
 | 
			
		||||
@@ -66,12 +66,25 @@ services:
 | 
			
		||||
#        browser-chrome:
 | 
			
		||||
#            condition: service_started
 | 
			
		||||
 | 
			
		||||
#    browser-chrome:
 | 
			
		||||
#        hostname: browser-chrome
 | 
			
		||||
#        image: selenium/standalone-chrome-debug:3.141.59
 | 
			
		||||
#        environment:
 | 
			
		||||
#            - VNC_NO_PASSWORD=1
 | 
			
		||||
#            - SCREEN_WIDTH=1920
 | 
			
		||||
#            - SCREEN_HEIGHT=1080
 | 
			
		||||
#            - SCREEN_DEPTH=24
 | 
			
		||||
#        volumes:
 | 
			
		||||
#            # Workaround to avoid the browser crashing inside a docker container
 | 
			
		||||
#            # See https://github.com/SeleniumHQ/docker-selenium#quick-start
 | 
			
		||||
#            - /dev/shm:/dev/shm
 | 
			
		||||
#        restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
     # Used for fetching pages via Playwright+Chrome where you need Javascript support.
 | 
			
		||||
     # Note: Playwright/browserless not supported on ARM type devices (rPi etc)
 | 
			
		||||
     # RECOMMENDED FOR FETCHING PAGES WITH CHROME
 | 
			
		||||
#    playwright-chrome:
 | 
			
		||||
#        hostname: playwright-chrome
 | 
			
		||||
#        image: browserless/chrome:1.60-chrome-stable
 | 
			
		||||
#        image: browserless/chrome
 | 
			
		||||
#        restart: unless-stopped
 | 
			
		||||
#        environment:
 | 
			
		||||
#            - SCREEN_WIDTH=1920
 | 
			
		||||
@@ -88,23 +101,6 @@ services:
 | 
			
		||||
#             Ignore HTTPS errors, like for self-signed certs
 | 
			
		||||
#            - DEFAULT_IGNORE_HTTPS_ERRORS=true
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
     # Used for fetching pages via Playwright+Chrome where you need Javascript support.
 | 
			
		||||
     # Note: works well but is deprecated, doesnt fetch full page screenshots and other issues
 | 
			
		||||
#    browser-chrome:
 | 
			
		||||
#        hostname: browser-chrome
 | 
			
		||||
#        image: selenium/standalone-chrome:4
 | 
			
		||||
#        environment:
 | 
			
		||||
#            - VNC_NO_PASSWORD=1
 | 
			
		||||
#            - SCREEN_WIDTH=1920
 | 
			
		||||
#            - SCREEN_HEIGHT=1080
 | 
			
		||||
#            - SCREEN_DEPTH=24
 | 
			
		||||
#        volumes:
 | 
			
		||||
#            # Workaround to avoid the browser crashing inside a docker container
 | 
			
		||||
#            # See https://github.com/SeleniumHQ/docker-selenium#quick-start
 | 
			
		||||
#            - /dev/shm:/dev/shm
 | 
			
		||||
#        restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
volumes:
 | 
			
		||||
  changedetection-data:
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
eventlet>=0.31.0
 | 
			
		||||
feedgen~=0.9
 | 
			
		||||
flask-compress
 | 
			
		||||
flask-login~=0.6
 | 
			
		||||
flask-login~=0.5
 | 
			
		||||
flask-paginate
 | 
			
		||||
flask_expects_json~=1.7
 | 
			
		||||
flask_restful
 | 
			
		||||
flask_wtf
 | 
			
		||||
flask~=2.3
 | 
			
		||||
flask~=2.0
 | 
			
		||||
inscriptis~=2.2
 | 
			
		||||
pytz
 | 
			
		||||
timeago~=1.0
 | 
			
		||||
@@ -33,7 +33,7 @@ dnspython<2.3.0
 | 
			
		||||
# jq not available on Windows so must be installed manually
 | 
			
		||||
 | 
			
		||||
# Notification library
 | 
			
		||||
apprise~=1.6.0
 | 
			
		||||
apprise~=1.5.0
 | 
			
		||||
 | 
			
		||||
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
 | 
			
		||||
paho-mqtt
 | 
			
		||||
@@ -49,18 +49,21 @@ beautifulsoup4
 | 
			
		||||
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
 | 
			
		||||
lxml
 | 
			
		||||
 | 
			
		||||
selenium~=4.14.0
 | 
			
		||||
# 3.141 was missing socksVersion, 3.150 was not in pypi, so we try 4.1.0
 | 
			
		||||
selenium~=4.1.0
 | 
			
		||||
 | 
			
		||||
werkzeug
 | 
			
		||||
# https://stackoverflow.com/questions/71652965/importerror-cannot-import-name-safe-str-cmp-from-werkzeug-security/71653849#71653849
 | 
			
		||||
# ImportError: cannot import name 'safe_str_cmp' from 'werkzeug.security'
 | 
			
		||||
# need to revisit flask login versions
 | 
			
		||||
werkzeug~=2.0.0
 | 
			
		||||
 | 
			
		||||
# Templating, so far just in the URLs but in the future can be for the notifications also
 | 
			
		||||
jinja2~=3.1
 | 
			
		||||
jinja2-time
 | 
			
		||||
openpyxl
 | 
			
		||||
 | 
			
		||||
# https://peps.python.org/pep-0508/#environment-markers
 | 
			
		||||
# https://github.com/dgtlmoon/changedetection.io/pull/1009
 | 
			
		||||
jq~=1.3; python_version >= "3.8" and sys_platform == "darwin"
 | 
			
		||||
jq~=1.3; python_version >= "3.8" and sys_platform == "linux"
 | 
			
		||||
jq~=1.3 ;python_version >= "3.8" and sys_platform == "linux"
 | 
			
		||||
 | 
			
		||||
# Any current modern version, required so far for screenshot PNG->JPEG conversion but will be used more in the future
 | 
			
		||||
pillow
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user