mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-11-04 00:27:48 +00:00 
			
		
		
		
	Compare commits
	
		
			78 Commits
		
	
	
		
			drop-arm-v
			...
			0.47.03
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					f7d4e58613 | ||
| 
						 | 
					5bb47e47db | ||
| 
						 | 
					03151da68e | ||
| 
						 | 
					a16a70229d | ||
| 
						 | 
					9476c1076b | ||
| 
						 | 
					a4959b5971 | ||
| 
						 | 
					a278fa22f2 | ||
| 
						 | 
					d39530b261 | ||
| 
						 | 
					d4b4355ff5 | ||
| 
						 | 
					c1c8de3104 | ||
| 
						 | 
					5a768d7db3 | ||
| 
						 | 
					f38429ec93 | ||
| 
						 | 
					783926962d | ||
| 
						 | 
					6cd1d50a4f | ||
| 
						 | 
					54a4970a4c | ||
| 
						 | 
					fd00453e6d | ||
| 
						 | 
					2842ffb205 | ||
| 
						 | 
					ec4e2f5649 | ||
| 
						 | 
					fe8e3d1cb1 | ||
| 
						 | 
					69fbafbdb7 | ||
| 
						 | 
					f255165571 | ||
| 
						 | 
					7ff34baa90 | ||
| 
						 | 
					043378d09c | ||
| 
						 | 
					af4bafcff8 | ||
| 
						 | 
					b656338c63 | ||
| 
						 | 
					97af190910 | ||
| 
						 | 
					e9e063e18e | ||
| 
						 | 
					45c444d0db | ||
| 
						 | 
					00458b95c4 | ||
| 
						 | 
					dad9760832 | ||
| 
						 | 
					e2c2a76cb2 | ||
| 
						 | 
					5b34aece96 | ||
| 
						 | 
					1b625dc18a | ||
| 
						 | 
					367afc81e9 | ||
| 
						 | 
					ddfbef6db3 | ||
| 
						 | 
					e173954cdd | ||
| 
						 | 
					e830fb2320 | ||
| 
						 | 
					c6589ee1b4 | ||
| 
						 | 
					dc936a2e8a | ||
| 
						 | 
					8c1527c1ad | ||
| 
						 | 
					a5ff1cd1d7 | ||
| 
						 | 
					543cb205d2 | ||
| 
						 | 
					273adfa0a4 | ||
| 
						 | 
					8ecfd17973 | ||
| 
						 | 
					19f3851c9d | ||
| 
						 | 
					7f2fa20318 | ||
| 
						 | 
					e16814e40b | ||
| 
						 | 
					337fcab3f1 | ||
| 
						 | 
					eaccd6026c | ||
| 
						 | 
					5b70625eaa | ||
| 
						 | 
					60d292107d | ||
| 
						 | 
					1cb38347da | ||
| 
						 | 
					55fe2abf42 | ||
| 
						 | 
					4225900ec3 | ||
| 
						 | 
					1fb4342488 | ||
| 
						 | 
					7071df061a | ||
| 
						 | 
					6dd1fa2b88 | ||
| 
						 | 
					371f85d544 | ||
| 
						 | 
					932cf15e1e | ||
| 
						 | 
					bf0d410d32 | ||
| 
						 | 
					730f37c7ba | ||
| 
						 | 
					8a35d62e02 | ||
| 
						 | 
					f527744024 | ||
| 
						 | 
					71c9b1273c | ||
| 
						 | 
					ec68450df1 | ||
| 
						 | 
					2fd762a783 | ||
| 
						 | 
					d7e85ffe8f | ||
| 
						 | 
					d23a301826 | ||
| 
						 | 
					3ce6096fdb | ||
| 
						 | 
					8acdcdd861 | ||
| 
						 | 
					755cba33de | ||
| 
						 | 
					8aae7dfae0 | ||
| 
						 | 
					ed00f67a80 | ||
| 
						 | 
					44e7e142f8 | ||
| 
						 | 
					fe704e05a3 | ||
| 
						 | 
					e756e0af5e | ||
| 
						 | 
					c0b6c8581e | ||
| 
						 | 
					de558f208f | 
							
								
								
									
										4
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							@@ -95,7 +95,7 @@ jobs:
 | 
			
		||||
          push: true
 | 
			
		||||
          tags: |
 | 
			
		||||
            ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:dev,ghcr.io/${{ github.repository }}:dev
 | 
			
		||||
          platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
 | 
			
		||||
          platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
 | 
			
		||||
          cache-from: type=gha
 | 
			
		||||
          cache-to: type=gha,mode=max
 | 
			
		||||
 | 
			
		||||
@@ -116,7 +116,7 @@ jobs:
 | 
			
		||||
            ghcr.io/dgtlmoon/changedetection.io:${{ github.event.release.tag_name }}
 | 
			
		||||
            ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest
 | 
			
		||||
            ghcr.io/dgtlmoon/changedetection.io:latest
 | 
			
		||||
          platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
 | 
			
		||||
          platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
 | 
			
		||||
          cache-from: type=gha
 | 
			
		||||
          cache-to: type=gha,mode=max
 | 
			
		||||
# Looks like this was disabled
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/test-container-build.yml
									
									
									
									
										vendored
									
									
								
							@@ -64,7 +64,7 @@ jobs:
 | 
			
		||||
          with:
 | 
			
		||||
            context: ./
 | 
			
		||||
            file: ./Dockerfile
 | 
			
		||||
            platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
 | 
			
		||||
            platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
 | 
			
		||||
            cache-from: type=local,src=/tmp/.buildx-cache
 | 
			
		||||
            cache-to: type=local,dest=/tmp/.buildx-cache
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -37,6 +37,7 @@ RUN pip install --target=/dependencies playwright~=1.41.2 \
 | 
			
		||||
 | 
			
		||||
# Final image stage
 | 
			
		||||
FROM python:${PYTHON_VERSION}-slim-bookworm
 | 
			
		||||
LABEL org.opencontainers.image.source="https://github.com/dgtlmoon/changedetection.io"
 | 
			
		||||
 | 
			
		||||
RUN apt-get update && apt-get install -y --no-install-recommends \
 | 
			
		||||
    libxslt1.1 \
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
recursive-include changedetectionio/api *
 | 
			
		||||
recursive-include changedetectionio/apprise_plugin *
 | 
			
		||||
recursive-include changedetectionio/blueprint *
 | 
			
		||||
recursive-include changedetectionio/content_fetchers *
 | 
			
		||||
recursive-include changedetectionio/model *
 | 
			
		||||
 
 | 
			
		||||
@@ -43,7 +43,7 @@ Requires Playwright to be enabled.
 | 
			
		||||
 | 
			
		||||
### Awesome restock and price change notifications
 | 
			
		||||
 | 
			
		||||
Enable the _"Re-stock & Price detection for single product pages"_ option to activate the best way to monitor product pricing.
 | 
			
		||||
Enable the _"Re-stock & Price detection for single product pages"_ option to activate the best way to monitor product pricing, this will extract any meta-data in the HTML page and give you many options to follow the pricing of the product.
 | 
			
		||||
 | 
			
		||||
Easily organise and monitor prices for products from the dashboard, get alerts and notifications when the price of a product changes or comes back in stock again!
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
# Only exists for direct CLI usage
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
 | 
			
		||||
 | 
			
		||||
__version__ = '0.45.26'
 | 
			
		||||
__version__ = '0.47.03'
 | 
			
		||||
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
from json.decoder import JSONDecodeError
 | 
			
		||||
 
 | 
			
		||||
@@ -58,7 +58,7 @@ class Watch(Resource):
 | 
			
		||||
            abort(404, message='No watch exists with the UUID of {}'.format(uuid))
 | 
			
		||||
 | 
			
		||||
        if request.args.get('recheck'):
 | 
			
		||||
            self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
            self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
            return "OK", 200
 | 
			
		||||
        if request.args.get('paused', '') == 'paused':
 | 
			
		||||
            self.datastore.data['watching'].get(uuid).pause()
 | 
			
		||||
@@ -246,7 +246,7 @@ class CreateWatch(Resource):
 | 
			
		||||
 | 
			
		||||
        new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
 | 
			
		||||
        if new_uuid:
 | 
			
		||||
            self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
            self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
 | 
			
		||||
            return {'uuid': new_uuid}, 201
 | 
			
		||||
        else:
 | 
			
		||||
            return "Invalid or unsupported URL", 400
 | 
			
		||||
@@ -303,7 +303,7 @@ class CreateWatch(Resource):
 | 
			
		||||
 | 
			
		||||
        if request.args.get('recheck_all'):
 | 
			
		||||
            for uuid in self.datastore.data['watching'].keys():
 | 
			
		||||
                self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
                self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
            return {'status': "OK"}, 200
 | 
			
		||||
 | 
			
		||||
        return list, 200
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										81
									
								
								changedetectionio/apprise_plugin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								changedetectionio/apprise_plugin/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
# include the decorator
 | 
			
		||||
from apprise.decorators import notify
 | 
			
		||||
from loguru import logger
 | 
			
		||||
 | 
			
		||||
@notify(on="delete")
 | 
			
		||||
@notify(on="deletes")
 | 
			
		||||
@notify(on="get")
 | 
			
		||||
@notify(on="gets")
 | 
			
		||||
@notify(on="post")
 | 
			
		||||
@notify(on="posts")
 | 
			
		||||
@notify(on="put")
 | 
			
		||||
@notify(on="puts")
 | 
			
		||||
def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
 | 
			
		||||
    import requests
 | 
			
		||||
    import json
 | 
			
		||||
    from apprise.utils import parse_url as apprise_parse_url
 | 
			
		||||
    from apprise import URLBase
 | 
			
		||||
 | 
			
		||||
    url = kwargs['meta'].get('url')
 | 
			
		||||
 | 
			
		||||
    if url.startswith('post'):
 | 
			
		||||
        r = requests.post
 | 
			
		||||
    elif url.startswith('get'):
 | 
			
		||||
        r = requests.get
 | 
			
		||||
    elif url.startswith('put'):
 | 
			
		||||
        r = requests.put
 | 
			
		||||
    elif url.startswith('delete'):
 | 
			
		||||
        r = requests.delete
 | 
			
		||||
 | 
			
		||||
    url = url.replace('post://', 'http://')
 | 
			
		||||
    url = url.replace('posts://', 'https://')
 | 
			
		||||
    url = url.replace('put://', 'http://')
 | 
			
		||||
    url = url.replace('puts://', 'https://')
 | 
			
		||||
    url = url.replace('get://', 'http://')
 | 
			
		||||
    url = url.replace('gets://', 'https://')
 | 
			
		||||
    url = url.replace('put://', 'http://')
 | 
			
		||||
    url = url.replace('puts://', 'https://')
 | 
			
		||||
    url = url.replace('delete://', 'http://')
 | 
			
		||||
    url = url.replace('deletes://', 'https://')
 | 
			
		||||
 | 
			
		||||
    headers = {}
 | 
			
		||||
    params = {}
 | 
			
		||||
    auth = None
 | 
			
		||||
 | 
			
		||||
    # Convert /foobar?+some-header=hello to proper header dictionary
 | 
			
		||||
    results = apprise_parse_url(url)
 | 
			
		||||
    if results:
 | 
			
		||||
        # Add our headers that the user can potentially over-ride if they wish
 | 
			
		||||
        # to to our returned result set and tidy entries by unquoting them
 | 
			
		||||
        headers = {URLBase.unquote(x): URLBase.unquote(y)
 | 
			
		||||
                   for x, y in results['qsd+'].items()}
 | 
			
		||||
 | 
			
		||||
        # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
 | 
			
		||||
        # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
 | 
			
		||||
        # but here we are making straight requests, so we need todo convert this against apprise's logic
 | 
			
		||||
        for k, v in results['qsd'].items():
 | 
			
		||||
            if not k.strip('+-') in results['qsd+'].keys():
 | 
			
		||||
                params[URLBase.unquote(k)] = URLBase.unquote(v)
 | 
			
		||||
 | 
			
		||||
        # Determine Authentication
 | 
			
		||||
        auth = ''
 | 
			
		||||
        if results.get('user') and results.get('password'):
 | 
			
		||||
            auth = (URLBase.unquote(results.get('user')), URLBase.unquote(results.get('user')))
 | 
			
		||||
        elif results.get('user'):
 | 
			
		||||
            auth = (URLBase.unquote(results.get('user')))
 | 
			
		||||
 | 
			
		||||
    # Try to auto-guess if it's JSON
 | 
			
		||||
    h = 'application/json; charset=utf-8'
 | 
			
		||||
    try:
 | 
			
		||||
        json.loads(body)
 | 
			
		||||
        headers['Content-Type'] = h
 | 
			
		||||
    except ValueError as e:
 | 
			
		||||
        logger.warning(f"Could not automatically add '{h}' header to the {kwargs['meta'].get('schema')}:// notification because the document failed to parse as JSON: {e}")
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    r(results.get('url'),
 | 
			
		||||
      auth=auth,
 | 
			
		||||
      data=body.encode('utf-8') if type(body) is str else body,
 | 
			
		||||
      headers=headers,
 | 
			
		||||
      params=params
 | 
			
		||||
      )
 | 
			
		||||
@@ -85,7 +85,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
        browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui(
 | 
			
		||||
            playwright_browser=browsersteps_start_session['browser'],
 | 
			
		||||
            proxy=proxy,
 | 
			
		||||
            start_url=datastore.data['watching'][watch_uuid].get('url')
 | 
			
		||||
            start_url=datastore.data['watching'][watch_uuid].get('url'),
 | 
			
		||||
            headers=datastore.data['watching'][watch_uuid].get('headers')
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # For test
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
@@ -25,6 +25,7 @@ browser_step_ui_config = {'Choose one': '0 0',
 | 
			
		||||
                          'Click element if exists': '1 0',
 | 
			
		||||
                          'Click element': '1 0',
 | 
			
		||||
                          'Click element containing text': '0 1',
 | 
			
		||||
                          'Click element containing text if exists': '0 1',
 | 
			
		||||
                          'Enter text in field': '1 1',
 | 
			
		||||
                          'Execute JS': '0 1',
 | 
			
		||||
#                          'Extract text and use as filter': '1 0',
 | 
			
		||||
@@ -96,12 +97,24 @@ class steppable_browser_interface():
 | 
			
		||||
        return self.action_goto_url(value=self.start_url)
 | 
			
		||||
 | 
			
		||||
    def action_click_element_containing_text(self, selector=None, value=''):
 | 
			
		||||
        logger.debug("Clicking element containing text")
 | 
			
		||||
        if not len(value.strip()):
 | 
			
		||||
            return
 | 
			
		||||
        elem = self.page.get_by_text(value)
 | 
			
		||||
        if elem.count():
 | 
			
		||||
            elem.first.click(delay=randint(200, 500), timeout=3000)
 | 
			
		||||
 | 
			
		||||
    def action_click_element_containing_text_if_exists(self, selector=None, value=''):
 | 
			
		||||
        logger.debug("Clicking element containing text if exists")
 | 
			
		||||
        if not len(value.strip()):
 | 
			
		||||
            return
 | 
			
		||||
        elem = self.page.get_by_text(value)
 | 
			
		||||
        logger.debug(f"Clicking element containing text - {elem.count()} elements found")
 | 
			
		||||
        if elem.count():
 | 
			
		||||
            elem.first.click(delay=randint(200, 500), timeout=3000)
 | 
			
		||||
        else:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
    def action_enter_text_in_field(self, selector, value):
 | 
			
		||||
        if not len(selector.strip()):
 | 
			
		||||
            return
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,7 @@
 | 
			
		||||
import importlib
 | 
			
		||||
from concurrent.futures import ThreadPoolExecutor
 | 
			
		||||
 | 
			
		||||
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
 | 
			
		||||
from changedetectionio.store import ChangeDetectionStore
 | 
			
		||||
 | 
			
		||||
from functools import wraps
 | 
			
		||||
@@ -30,7 +33,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
    def long_task(uuid, preferred_proxy):
 | 
			
		||||
        import time
 | 
			
		||||
        from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions
 | 
			
		||||
        from changedetectionio.processors.text_json_diff import text_json_diff
 | 
			
		||||
        from changedetectionio.safe_jinja import render as jinja_render
 | 
			
		||||
 | 
			
		||||
        status = {'status': '', 'length': 0, 'text': ''}
 | 
			
		||||
@@ -38,8 +40,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
        contents = ''
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        try:
 | 
			
		||||
            update_handler = text_json_diff.perform_site_check(datastore=datastore, watch_uuid=uuid)
 | 
			
		||||
            update_handler.call_browser()
 | 
			
		||||
            processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
 | 
			
		||||
            update_handler = processor_module.perform_site_check(datastore=datastore,
 | 
			
		||||
                                                                 watch_uuid=uuid
 | 
			
		||||
                                                                 )
 | 
			
		||||
 | 
			
		||||
            update_handler.call_browser(preferred_proxy_id=preferred_proxy)
 | 
			
		||||
        # title, size is len contents not len xfer
 | 
			
		||||
        except content_fetcher_exceptions.Non200ErrorCodeReceived as e:
 | 
			
		||||
            if e.status_code == 404:
 | 
			
		||||
@@ -48,7 +54,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
                status.update({'status': 'ERROR', 'length': len(contents), 'text': f"{e.status_code} - Access denied"})
 | 
			
		||||
            else:
 | 
			
		||||
                status.update({'status': 'ERROR', 'length': len(contents), 'text': f"Status code: {e.status_code}"})
 | 
			
		||||
        except text_json_diff.FilterNotFoundInResponse:
 | 
			
		||||
        except FilterNotFoundInResponse:
 | 
			
		||||
            status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but CSS/xPath filter not found (page changed layout?)"})
 | 
			
		||||
        except content_fetcher_exceptions.EmptyReply as e:
 | 
			
		||||
            if e.status_code == 403 or e.status_code == 401:
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
 | 
			
		||||
        datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
 | 
			
		||||
        datastore.data['watching'][uuid]['processor'] = 'restock_diff'
 | 
			
		||||
        datastore.data['watching'][uuid].clear_watch()
 | 
			
		||||
        update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False}))
 | 
			
		||||
        update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
        return redirect(url_for("index"))
 | 
			
		||||
 | 
			
		||||
    @login_required
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,6 @@
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
 | 
			
		||||
<!--<script src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script>-->
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
 | 
			
		||||
 | 
			
		||||
<div class="edit-form monospaced-textarea">
 | 
			
		||||
@@ -58,9 +57,9 @@ xpath://body/div/span[contains(@class, 'example-class')]",
 | 
			
		||||
                        {% if '/text()' in  field %}
 | 
			
		||||
                          <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                        <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br>
 | 
			
		||||
 | 
			
		||||
                    <ul>
 | 
			
		||||
                        <span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
 | 
			
		||||
                    <div data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div>
 | 
			
		||||
                    <ul id="advanced-help-selectors">
 | 
			
		||||
                        <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
 | 
			
		||||
                        <li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed).
 | 
			
		||||
                            <ul>
 | 
			
		||||
@@ -89,11 +88,13 @@ xpath://body/div/span[contains(@class, 'example-class')]",
 | 
			
		||||
                    {{ render_field(form.subtractive_selectors, rows=5, placeholder="header
 | 
			
		||||
footer
 | 
			
		||||
nav
 | 
			
		||||
.stockticker") }}
 | 
			
		||||
.stockticker
 | 
			
		||||
//*[contains(text(), 'Advertisement')]") }}
 | 
			
		||||
                    <span class="pure-form-message-inline">
 | 
			
		||||
                        <ul>
 | 
			
		||||
                          <li> Remove HTML element(s) by CSS selector before text conversion. </li>
 | 
			
		||||
                          <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
 | 
			
		||||
                          <li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
 | 
			
		||||
                          <li> Don't paste HTML here, use only CSS and XPath selectors </li>
 | 
			
		||||
                          <li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
 | 
			
		||||
                        </ul>
 | 
			
		||||
                      </span>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,9 @@ from loguru import logger
 | 
			
		||||
from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary'
 | 
			
		||||
# Visual Selector scraper - 'Button' is there because some sites have <button>OUT OF STOCK</button>.
 | 
			
		||||
visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary,button'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# available_fetchers() will scan this implementation looking for anything starting with html_
 | 
			
		||||
# this information is used in the form selections
 | 
			
		||||
 
 | 
			
		||||
@@ -65,8 +65,8 @@ class Fetcher():
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        import importlib.resources
 | 
			
		||||
        self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
 | 
			
		||||
        self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text()
 | 
			
		||||
        self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
 | 
			
		||||
        self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def get_error(self):
 | 
			
		||||
@@ -81,7 +81,8 @@ class Fetcher():
 | 
			
		||||
            request_method,
 | 
			
		||||
            ignore_status_codes=False,
 | 
			
		||||
            current_include_filters=None,
 | 
			
		||||
            is_binary=False):
 | 
			
		||||
            is_binary=False,
 | 
			
		||||
            empty_pages_are_a_change=False):
 | 
			
		||||
        # Should set self.error, self.status_code and self.content
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -83,7 +83,8 @@ class fetcher(Fetcher):
 | 
			
		||||
            request_method,
 | 
			
		||||
            ignore_status_codes=False,
 | 
			
		||||
            current_include_filters=None,
 | 
			
		||||
            is_binary=False):
 | 
			
		||||
            is_binary=False,
 | 
			
		||||
            empty_pages_are_a_change=False):
 | 
			
		||||
 | 
			
		||||
        from playwright.sync_api import sync_playwright
 | 
			
		||||
        import playwright._impl._errors
 | 
			
		||||
@@ -130,7 +131,7 @@ class fetcher(Fetcher):
 | 
			
		||||
            if response is None:
 | 
			
		||||
                context.close()
 | 
			
		||||
                browser.close()
 | 
			
		||||
                logger.debug("Content Fetcher > Response object was none")
 | 
			
		||||
                logger.debug("Content Fetcher > Response object from the browser communication was none")
 | 
			
		||||
                raise EmptyReply(url=url, status_code=None)
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
@@ -166,10 +167,10 @@ class fetcher(Fetcher):
 | 
			
		||||
 | 
			
		||||
                raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
 | 
			
		||||
 | 
			
		||||
            if len(self.page.content().strip()) == 0:
 | 
			
		||||
            if not empty_pages_are_a_change and len(self.page.content().strip()) == 0:
 | 
			
		||||
                logger.debug("Content Fetcher > Content was empty, empty_pages_are_a_change = False")
 | 
			
		||||
                context.close()
 | 
			
		||||
                browser.close()
 | 
			
		||||
                logger.debug("Content Fetcher > Content was empty")
 | 
			
		||||
                raise EmptyReply(url=url, status_code=response.status)
 | 
			
		||||
 | 
			
		||||
            # Run Browser Steps here
 | 
			
		||||
 
 | 
			
		||||
@@ -75,7 +75,8 @@ class fetcher(Fetcher):
 | 
			
		||||
                         request_method,
 | 
			
		||||
                         ignore_status_codes,
 | 
			
		||||
                         current_include_filters,
 | 
			
		||||
                         is_binary
 | 
			
		||||
                         is_binary,
 | 
			
		||||
                         empty_pages_are_a_change
 | 
			
		||||
                         ):
 | 
			
		||||
 | 
			
		||||
        from changedetectionio.content_fetchers import visualselector_xpath_selectors
 | 
			
		||||
@@ -153,7 +154,7 @@ class fetcher(Fetcher):
 | 
			
		||||
        if response is None:
 | 
			
		||||
            await self.page.close()
 | 
			
		||||
            await browser.close()
 | 
			
		||||
            logger.warning("Content Fetcher > Response object was none")
 | 
			
		||||
            logger.warning("Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content)")
 | 
			
		||||
            raise EmptyReply(url=url, status_code=None)
 | 
			
		||||
 | 
			
		||||
        self.headers = response.headers
 | 
			
		||||
@@ -186,10 +187,11 @@ class fetcher(Fetcher):
 | 
			
		||||
 | 
			
		||||
            raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
 | 
			
		||||
        content = await self.page.content
 | 
			
		||||
        if len(content.strip()) == 0:
 | 
			
		||||
 | 
			
		||||
        if not empty_pages_are_a_change and len(content.strip()) == 0:
 | 
			
		||||
            logger.error("Content Fetcher > Content was empty (empty_pages_are_a_change is False), closing browsers")
 | 
			
		||||
            await self.page.close()
 | 
			
		||||
            await browser.close()
 | 
			
		||||
            logger.error("Content Fetcher > Content was empty")
 | 
			
		||||
            raise EmptyReply(url=url, status_code=response.status)
 | 
			
		||||
 | 
			
		||||
        # Run Browser Steps here
 | 
			
		||||
@@ -247,7 +249,7 @@ class fetcher(Fetcher):
 | 
			
		||||
        await self.fetch_page(**kwargs)
 | 
			
		||||
 | 
			
		||||
    def run(self, url, timeout, request_headers, request_body, request_method, ignore_status_codes=False,
 | 
			
		||||
            current_include_filters=None, is_binary=False):
 | 
			
		||||
            current_include_filters=None, is_binary=False, empty_pages_are_a_change=False):
 | 
			
		||||
 | 
			
		||||
        #@todo make update_worker async which could run any of these content_fetchers within memory and time constraints
 | 
			
		||||
        max_time = os.getenv('PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS', 180)
 | 
			
		||||
@@ -262,7 +264,8 @@ class fetcher(Fetcher):
 | 
			
		||||
                request_method=request_method,
 | 
			
		||||
                ignore_status_codes=ignore_status_codes,
 | 
			
		||||
                current_include_filters=current_include_filters,
 | 
			
		||||
                is_binary=is_binary
 | 
			
		||||
                is_binary=is_binary,
 | 
			
		||||
                empty_pages_are_a_change=empty_pages_are_a_change
 | 
			
		||||
            ), timeout=max_time))
 | 
			
		||||
        except asyncio.TimeoutError:
 | 
			
		||||
            raise(BrowserFetchTimedOut(msg=f"Browser connected but was unable to process the page in {max_time} seconds."))
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,7 @@
 | 
			
		||||
from loguru import logger
 | 
			
		||||
import hashlib
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
import chardet
 | 
			
		||||
import requests
 | 
			
		||||
 | 
			
		||||
from changedetectionio import strtobool
 | 
			
		||||
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
 | 
			
		||||
from changedetectionio.content_fetchers.base import Fetcher
 | 
			
		||||
 | 
			
		||||
@@ -25,7 +23,11 @@ class fetcher(Fetcher):
 | 
			
		||||
            request_method,
 | 
			
		||||
            ignore_status_codes=False,
 | 
			
		||||
            current_include_filters=None,
 | 
			
		||||
            is_binary=False):
 | 
			
		||||
            is_binary=False,
 | 
			
		||||
            empty_pages_are_a_change=False):
 | 
			
		||||
 | 
			
		||||
        import chardet
 | 
			
		||||
        import requests
 | 
			
		||||
 | 
			
		||||
        if self.browser_steps_get_valid_steps():
 | 
			
		||||
            raise BrowserStepsInUnsupportedFetcher(url=url)
 | 
			
		||||
@@ -45,13 +47,19 @@ class fetcher(Fetcher):
 | 
			
		||||
            if self.system_https_proxy:
 | 
			
		||||
                proxies['https'] = self.system_https_proxy
 | 
			
		||||
 | 
			
		||||
        r = requests.request(method=request_method,
 | 
			
		||||
                             data=request_body,
 | 
			
		||||
                             url=url,
 | 
			
		||||
                             headers=request_headers,
 | 
			
		||||
                             timeout=timeout,
 | 
			
		||||
                             proxies=proxies,
 | 
			
		||||
                             verify=False)
 | 
			
		||||
        session = requests.Session()
 | 
			
		||||
 | 
			
		||||
        if strtobool(os.getenv('ALLOW_FILE_URI', 'false')) and url.startswith('file://'):
 | 
			
		||||
            from requests_file import FileAdapter
 | 
			
		||||
            session.mount('file://', FileAdapter())
 | 
			
		||||
 | 
			
		||||
        r = session.request(method=request_method,
 | 
			
		||||
                            data=request_body.encode('utf-8') if type(request_body) is str else request_body,
 | 
			
		||||
                            url=url,
 | 
			
		||||
                            headers=request_headers,
 | 
			
		||||
                            timeout=timeout,
 | 
			
		||||
                            proxies=proxies,
 | 
			
		||||
                            verify=False)
 | 
			
		||||
 | 
			
		||||
        # If the response did not tell us what encoding format to expect, Then use chardet to override what `requests` thinks.
 | 
			
		||||
        # For example - some sites don't tell us it's utf-8, but return utf-8 content
 | 
			
		||||
@@ -67,7 +75,11 @@ class fetcher(Fetcher):
 | 
			
		||||
        self.headers = r.headers
 | 
			
		||||
 | 
			
		||||
        if not r.content or not len(r.content):
 | 
			
		||||
            raise EmptyReply(url=url, status_code=r.status_code)
 | 
			
		||||
            logger.debug(f"Requests returned empty content for '{url}'")
 | 
			
		||||
            if not empty_pages_are_a_change:
 | 
			
		||||
                raise EmptyReply(url=url, status_code=r.status_code)
 | 
			
		||||
            else:
 | 
			
		||||
                logger.debug(f"URL {url} gave zero byte content reply with Status Code {r.status_code}, but empty_pages_are_a_change = True")
 | 
			
		||||
 | 
			
		||||
        # @todo test this
 | 
			
		||||
        # @todo maybe you really want to test zero-byte return pages?
 | 
			
		||||
 
 | 
			
		||||
@@ -75,6 +75,7 @@ function isItemInStock() {
 | 
			
		||||
        'vergriffen',
 | 
			
		||||
        'vorbestellen',
 | 
			
		||||
        'vorbestellung ist bald möglich',
 | 
			
		||||
        'we don\'t currently have any',
 | 
			
		||||
        'we couldn\'t find any products that match',
 | 
			
		||||
        'we do not currently have an estimate of when this product will be back in stock.',
 | 
			
		||||
        'we don\'t know when or if this item will be back in stock.',
 | 
			
		||||
@@ -153,10 +154,14 @@ function isItemInStock() {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        elementText = "";
 | 
			
		||||
        if (element.tagName.toLowerCase() === "input") {
 | 
			
		||||
            elementText = element.value.toLowerCase().trim();
 | 
			
		||||
        } else {
 | 
			
		||||
            elementText = getElementBaseText(element);
 | 
			
		||||
        try {
 | 
			
		||||
            if (element.tagName.toLowerCase() === "input") {
 | 
			
		||||
                elementText = element.value.toLowerCase().trim();
 | 
			
		||||
            } else {
 | 
			
		||||
                elementText = getElementBaseText(element);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
            console.warn('stock-not-in-stock.js scraper - handling element for gettext failed', e);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (elementText.length) {
 | 
			
		||||
@@ -173,7 +178,8 @@ function isItemInStock() {
 | 
			
		||||
        const element = elementsToScan[i];
 | 
			
		||||
        // outside the 'fold' or some weird text in the heading area
 | 
			
		||||
        // .getBoundingClientRect() was causing a crash in chrome 119, can only be run on contentVisibility != hidden
 | 
			
		||||
        if (element.getBoundingClientRect().top + window.scrollY >= vh + 150 || element.getBoundingClientRect().top + window.scrollY <= 100) {
 | 
			
		||||
        // Note: theres also an automated test that places the 'out of stock' text fairly low down
 | 
			
		||||
        if (element.getBoundingClientRect().top + window.scrollY >= vh + 250 || element.getBoundingClientRect().top + window.scrollY <= 100) {
 | 
			
		||||
            continue
 | 
			
		||||
        }
 | 
			
		||||
        elementText = "";
 | 
			
		||||
@@ -187,7 +193,7 @@ function isItemInStock() {
 | 
			
		||||
            // and these mean its out of stock
 | 
			
		||||
            for (const outOfStockText of outOfStockTexts) {
 | 
			
		||||
                if (elementText.includes(outOfStockText)) {
 | 
			
		||||
                    console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}"`)
 | 
			
		||||
                    console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}" - offset top ${element.getBoundingClientRect().top}, page height is ${vh}`)
 | 
			
		||||
                    return outOfStockText; // item is out of stock
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -164,6 +164,15 @@ visibleElementsArray.forEach(function (element) {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let label = "not-interesting" // A placeholder, the actual labels for training are done by hand for now
 | 
			
		||||
 | 
			
		||||
    let text = element.textContent.trim().slice(0, 30).trim();
 | 
			
		||||
    while (/\n{2,}|\t{2,}/.test(text)) {
 | 
			
		||||
        text = text.replace(/\n{2,}/g, '\n').replace(/\t{2,}/g, '\t')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Try to identify any possible currency amounts "Sale: 4000" or "Sale now 3000 Kc", can help with the training.
 | 
			
		||||
    const hasDigitCurrency = (/\d/.test(text.slice(0, 6)) || /\d/.test(text.slice(-6)) ) &&  /([€£$¥₩₹]|USD|AUD|EUR|Kč|kr|SEK|,–)/.test(text) ;
 | 
			
		||||
 | 
			
		||||
    size_pos.push({
 | 
			
		||||
        xpath: xpath_result,
 | 
			
		||||
@@ -171,9 +180,16 @@ visibleElementsArray.forEach(function (element) {
 | 
			
		||||
        height: Math.round(bbox['height']),
 | 
			
		||||
        left: Math.floor(bbox['left']),
 | 
			
		||||
        top: Math.floor(bbox['top']) + scroll_y,
 | 
			
		||||
        // tagName used by Browser Steps
 | 
			
		||||
        tagName: (element.tagName) ? element.tagName.toLowerCase() : '',
 | 
			
		||||
        // tagtype used by Browser Steps
 | 
			
		||||
        tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '',
 | 
			
		||||
        isClickable: window.getComputedStyle(element).cursor == "pointer"
 | 
			
		||||
        isClickable: window.getComputedStyle(element).cursor === "pointer",
 | 
			
		||||
        // Used by the keras trainer
 | 
			
		||||
        fontSize: window.getComputedStyle(element).getPropertyValue('font-size'),
 | 
			
		||||
        fontWeight: window.getComputedStyle(element).getPropertyValue('font-weight'),
 | 
			
		||||
        hasDigitCurrency: hasDigitCurrency,
 | 
			
		||||
        label: label,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
@@ -214,7 +230,7 @@ if (include_filters.length) {
 | 
			
		||||
            console.log(e);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (results.length) {
 | 
			
		||||
        if (results != null && results.length) {
 | 
			
		||||
 | 
			
		||||
            // Iterate over the results
 | 
			
		||||
            results.forEach(node => {
 | 
			
		||||
 
 | 
			
		||||
@@ -56,7 +56,8 @@ class fetcher(Fetcher):
 | 
			
		||||
            request_method,
 | 
			
		||||
            ignore_status_codes=False,
 | 
			
		||||
            current_include_filters=None,
 | 
			
		||||
            is_binary=False):
 | 
			
		||||
            is_binary=False,
 | 
			
		||||
            empty_pages_are_a_change=False):
 | 
			
		||||
 | 
			
		||||
        from selenium import webdriver
 | 
			
		||||
        from selenium.webdriver.chrome.options import Options as ChromeOptions
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import datetime
 | 
			
		||||
 | 
			
		||||
import flask_login
 | 
			
		||||
import locale
 | 
			
		||||
import os
 | 
			
		||||
@@ -532,12 +533,22 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    def ajax_callback_send_notification_test(watch_uuid=None):
 | 
			
		||||
 | 
			
		||||
        # Watch_uuid could be unsuet in the case its used in tag editor, global setings
 | 
			
		||||
        # Watch_uuid could be unset in the case its used in tag editor, global setings
 | 
			
		||||
        import apprise
 | 
			
		||||
        import random
 | 
			
		||||
        from .apprise_asset import asset
 | 
			
		||||
        apobj = apprise.Apprise(asset=asset)
 | 
			
		||||
        # so that the custom endpoints are registered
 | 
			
		||||
        from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
 | 
			
		||||
        is_global_settings_form = request.args.get('mode', '') == 'global-settings'
 | 
			
		||||
        is_group_settings_form = request.args.get('mode', '') == 'group-settings'
 | 
			
		||||
 | 
			
		||||
        watch = datastore.data['watching'].get(watch_uuid) if watch_uuid else None
 | 
			
		||||
        # Use an existing random one on the global/main settings form
 | 
			
		||||
        if not watch_uuid and (is_global_settings_form or is_group_settings_form):
 | 
			
		||||
            logger.debug(f"Send test notification - Choosing random Watch {watch_uuid}")
 | 
			
		||||
            watch_uuid = random.choice(list(datastore.data['watching'].keys()))
 | 
			
		||||
 | 
			
		||||
        watch = datastore.data['watching'].get(watch_uuid)
 | 
			
		||||
 | 
			
		||||
        notification_urls = request.form['notification_urls'].strip().splitlines()
 | 
			
		||||
 | 
			
		||||
@@ -549,8 +560,6 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                    tag = datastore.tag_exists_by_name(k.strip())
 | 
			
		||||
                    notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None
 | 
			
		||||
 | 
			
		||||
        is_global_settings_form = request.args.get('mode', '') == 'global-settings'
 | 
			
		||||
        is_group_settings_form = request.args.get('mode', '') == 'group-settings'
 | 
			
		||||
        if not notification_urls and not is_global_settings_form and not is_group_settings_form:
 | 
			
		||||
            # In the global settings, use only what is typed currently in the text box
 | 
			
		||||
            logger.debug("Test notification - Trying by global system settings notifications")
 | 
			
		||||
@@ -569,7 +578,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        try:
 | 
			
		||||
            # use the same as when it is triggered, but then override it with the form test values
 | 
			
		||||
            n_object = {
 | 
			
		||||
                'watch_url': request.form['window_url'],
 | 
			
		||||
                'watch_url': request.form.get('window_url', "https://changedetection.io"),
 | 
			
		||||
                'notification_urls': notification_urls
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@@ -779,7 +788,6 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            # Recast it if need be to right data Watch handler
 | 
			
		||||
            watch_class = get_custom_watch_obj_for_processor(form.data.get('processor'))
 | 
			
		||||
            datastore.data['watching'][uuid] = watch_class(datastore_path=datastore_o.datastore_path, default=datastore.data['watching'][uuid])
 | 
			
		||||
 | 
			
		||||
            flash("Updated watch - unpaused!" if request.args.get('unpause_on_save') else "Updated watch.")
 | 
			
		||||
 | 
			
		||||
            # Re #286 - We wait for syncing new data to disk in another thread every 60 seconds
 | 
			
		||||
@@ -787,7 +795,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            datastore.needs_write_urgent = True
 | 
			
		||||
 | 
			
		||||
            # Queue the watch for immediate recheck, with a higher priority
 | 
			
		||||
            update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False}))
 | 
			
		||||
            update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
 | 
			
		||||
            # Diff page [edit] link should go back to diff page
 | 
			
		||||
            if request.args.get("next") and request.args.get("next") == 'diff':
 | 
			
		||||
@@ -968,7 +976,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                importer = import_url_list()
 | 
			
		||||
                importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff'))
 | 
			
		||||
                for uuid in importer.new_uuids:
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
 | 
			
		||||
                if len(importer.remaining_data) == 0:
 | 
			
		||||
                    return redirect(url_for('index'))
 | 
			
		||||
@@ -981,7 +989,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                d_importer = import_distill_io_json()
 | 
			
		||||
                d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore)
 | 
			
		||||
                for uuid in d_importer.new_uuids:
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
 | 
			
		||||
            # XLSX importer
 | 
			
		||||
            if request.files and request.files.get('xlsx_file'):
 | 
			
		||||
@@ -1005,7 +1013,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                    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}))
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
 | 
			
		||||
        # Could be some remaining, or we could be on GET
 | 
			
		||||
        form = forms.importForm(formdata=request.form if request.method == 'POST' else None)
 | 
			
		||||
@@ -1146,8 +1154,6 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    def preview_page(uuid):
 | 
			
		||||
        content = []
 | 
			
		||||
        ignored_line_numbers = []
 | 
			
		||||
        trigger_line_numbers = []
 | 
			
		||||
        versions = []
 | 
			
		||||
        timestamp = None
 | 
			
		||||
 | 
			
		||||
@@ -1164,11 +1170,10 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
 | 
			
		||||
        extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        is_html_webdriver = False
 | 
			
		||||
        if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
 | 
			
		||||
            is_html_webdriver = True
 | 
			
		||||
 | 
			
		||||
        triggered_line_numbers = []
 | 
			
		||||
        if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):
 | 
			
		||||
            flash("Preview unavailable - No fetch/check completed or triggers not reached", "error")
 | 
			
		||||
        else:
 | 
			
		||||
@@ -1181,31 +1186,12 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
                versions = list(watch.history.keys())
 | 
			
		||||
                tmp = watch.get_history_snapshot(timestamp).splitlines()
 | 
			
		||||
                content = watch.get_history_snapshot(timestamp)
 | 
			
		||||
 | 
			
		||||
                # Get what needs to be highlighted
 | 
			
		||||
                ignore_rules = watch.get('ignore_text', []) + datastore.data['settings']['application']['global_ignore_text']
 | 
			
		||||
 | 
			
		||||
                # .readlines will keep the \n, but we will parse it here again, in the future tidy this up
 | 
			
		||||
                ignored_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp),
 | 
			
		||||
                                                                    wordlist=ignore_rules,
 | 
			
		||||
                                                                    mode='line numbers'
 | 
			
		||||
                                                                    )
 | 
			
		||||
 | 
			
		||||
                trigger_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp),
 | 
			
		||||
                                                                    wordlist=watch['trigger_text'],
 | 
			
		||||
                                                                    mode='line numbers'
 | 
			
		||||
                                                                    )
 | 
			
		||||
                # Prepare the classes and lines used in the template
 | 
			
		||||
                i=0
 | 
			
		||||
                for l in tmp:
 | 
			
		||||
                    classes=[]
 | 
			
		||||
                    i+=1
 | 
			
		||||
                    if i in ignored_line_numbers:
 | 
			
		||||
                        classes.append('ignored')
 | 
			
		||||
                    if i in trigger_line_numbers:
 | 
			
		||||
                        classes.append('triggered')
 | 
			
		||||
                    content.append({'line': l, 'classes': ' '.join(classes)})
 | 
			
		||||
                triggered_line_numbers = html_tools.strip_ignore_text(content=content,
 | 
			
		||||
                                                                      wordlist=watch['trigger_text'],
 | 
			
		||||
                                                                      mode='line numbers'
 | 
			
		||||
                                                                      )
 | 
			
		||||
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''})
 | 
			
		||||
@@ -1216,8 +1202,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                                 history_n=watch.history_n,
 | 
			
		||||
                                 extra_stylesheets=extra_stylesheets,
 | 
			
		||||
                                 extra_title=f" - Diff - {watch.label} @ {timestamp}",
 | 
			
		||||
                                 ignored_line_numbers=ignored_line_numbers,
 | 
			
		||||
                                 triggered_line_numbers=trigger_line_numbers,
 | 
			
		||||
                                 triggered_line_numbers=triggered_line_numbers,
 | 
			
		||||
                                 current_diff_url=watch['url'],
 | 
			
		||||
                                 screenshot=watch.get_screenshot(),
 | 
			
		||||
                                 watch=watch,
 | 
			
		||||
@@ -1362,6 +1347,41 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            abort(404)
 | 
			
		||||
 | 
			
		||||
    @app.route("/edit/<string:uuid>/get-html", methods=['GET'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    def watch_get_latest_html(uuid):
 | 
			
		||||
        from io import BytesIO
 | 
			
		||||
        from flask import send_file
 | 
			
		||||
        import brotli
 | 
			
		||||
 | 
			
		||||
        watch = datastore.data['watching'].get(uuid)
 | 
			
		||||
        if watch and watch.history.keys() and os.path.isdir(watch.watch_data_dir):
 | 
			
		||||
            latest_filename = list(watch.history.keys())[-1]
 | 
			
		||||
            html_fname = os.path.join(watch.watch_data_dir, f"{latest_filename}.html.br")
 | 
			
		||||
            with open(html_fname, 'rb') as f:
 | 
			
		||||
                if html_fname.endswith('.br'):
 | 
			
		||||
                    # Read and decompress the Brotli file
 | 
			
		||||
                    decompressed_data = brotli.decompress(f.read())
 | 
			
		||||
                else:
 | 
			
		||||
                    decompressed_data = f.read()
 | 
			
		||||
 | 
			
		||||
            buffer = BytesIO(decompressed_data)
 | 
			
		||||
 | 
			
		||||
            return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        # Return a 500 error
 | 
			
		||||
        abort(500)
 | 
			
		||||
 | 
			
		||||
    # Ajax callback
 | 
			
		||||
    @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    def watch_get_preview_rendered(uuid):
 | 
			
		||||
        '''For when viewing the "preview" of the rendered text from inside of Edit'''
 | 
			
		||||
        from .processors.text_json_diff import prepare_filter_prevew
 | 
			
		||||
        return prepare_filter_prevew(watch_uuid=uuid, datastore=datastore)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    @app.route("/form/add/quickwatch", methods=['POST'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    def form_quick_watch_add():
 | 
			
		||||
@@ -1422,7 +1442,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        new_uuid = datastore.clone(uuid)
 | 
			
		||||
        if new_uuid:
 | 
			
		||||
            if not datastore.data['watching'].get(uuid).get('paused'):
 | 
			
		||||
                update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
                update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
 | 
			
		||||
            flash('Cloned.')
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
@@ -1443,7 +1463,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
        if uuid:
 | 
			
		||||
            if uuid not in running_uuids:
 | 
			
		||||
                update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False}))
 | 
			
		||||
                update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
            i = 1
 | 
			
		||||
 | 
			
		||||
        elif tag:
 | 
			
		||||
@@ -1454,7 +1474,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                        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})
 | 
			
		||||
                            queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})
 | 
			
		||||
                        )
 | 
			
		||||
                        i += 1
 | 
			
		||||
 | 
			
		||||
@@ -1464,9 +1484,8 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                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}))
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
 | 
			
		||||
                    i += 1
 | 
			
		||||
 | 
			
		||||
        flash(f"{i} watches queued for rechecking.")
 | 
			
		||||
        return redirect(url_for('index', tag=tag))
 | 
			
		||||
 | 
			
		||||
@@ -1523,7 +1542,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                uuid = uuid.strip()
 | 
			
		||||
                if datastore.data['watching'].get(uuid):
 | 
			
		||||
                    # Recheck and require a full reprocessing
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False}))
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
            flash("{} watches queued for rechecking".format(len(uuids)))
 | 
			
		||||
 | 
			
		||||
        elif (op == 'clear-errors'):
 | 
			
		||||
@@ -1847,7 +1866,7 @@ def ticker_thread_check_time_launch_checks():
 | 
			
		||||
                        f"{now - watch['last_checked']:0.2f}s since last checked")
 | 
			
		||||
 | 
			
		||||
                    # Into the queue with you
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=priority, item={'uuid': uuid, 'skip_when_checksum_same': True}))
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=priority, item={'uuid': uuid}))
 | 
			
		||||
 | 
			
		||||
                    # Reset for next time
 | 
			
		||||
                    watch.jitter_seconds = 0
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
from loguru import logger
 | 
			
		||||
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
 | 
			
		||||
@@ -221,7 +222,8 @@ class ValidateAppRiseServers(object):
 | 
			
		||||
    def __call__(self, form, field):
 | 
			
		||||
        import apprise
 | 
			
		||||
        apobj = apprise.Apprise()
 | 
			
		||||
 | 
			
		||||
        # so that the custom endpoints are registered
 | 
			
		||||
        from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
 | 
			
		||||
        for server_url in field.data:
 | 
			
		||||
            if not apobj.add(server_url):
 | 
			
		||||
                message = field.gettext('\'%s\' is not a valid AppRise URL.' % (server_url))
 | 
			
		||||
@@ -468,19 +470,21 @@ class processor_text_json_diff_form(commonSettingsForm):
 | 
			
		||||
 | 
			
		||||
    include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='')
 | 
			
		||||
 | 
			
		||||
    subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)])
 | 
			
		||||
    subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)])
 | 
			
		||||
 | 
			
		||||
    extract_text = StringListField('Extract text', [ValidateListRegex()])
 | 
			
		||||
 | 
			
		||||
    title = StringField('Title', default='')
 | 
			
		||||
 | 
			
		||||
    ignore_text = StringListField('Ignore text', [ValidateListRegex()])
 | 
			
		||||
    ignore_text = StringListField('Ignore lines containing', [ValidateListRegex()])
 | 
			
		||||
    headers = StringDictKeyValue('Request headers')
 | 
			
		||||
    body = TextAreaField('Request body', [validators.Optional()])
 | 
			
		||||
    method = SelectField('Request method', choices=valid_method, default=default_method)
 | 
			
		||||
    ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False)
 | 
			
		||||
    check_unique_lines = BooleanField('Only trigger when unique lines appear', default=False)
 | 
			
		||||
    check_unique_lines = BooleanField('Only trigger when unique lines appear in all history', default=False)
 | 
			
		||||
    remove_duplicate_lines = BooleanField('Remove duplicate lines of text', default=False)
 | 
			
		||||
    sort_text_alphabetically =  BooleanField('Sort text alphabetically', default=False)
 | 
			
		||||
    trim_text_whitespace = BooleanField('Trim whitespace before and after text', default=False)
 | 
			
		||||
 | 
			
		||||
    filter_text_added = BooleanField('Added lines', default=True)
 | 
			
		||||
    filter_text_replaced = BooleanField('Replaced/changed lines', default=True)
 | 
			
		||||
@@ -522,9 +526,16 @@ class processor_text_json_diff_form(commonSettingsForm):
 | 
			
		||||
        try:
 | 
			
		||||
            from changedetectionio.safe_jinja import render as jinja_render
 | 
			
		||||
            jinja_render(template_str=self.url.data)
 | 
			
		||||
        except ModuleNotFoundError as e:
 | 
			
		||||
            # incase jinja2_time or others is missing
 | 
			
		||||
            logger.error(e)
 | 
			
		||||
            self.url.errors.append(e)
 | 
			
		||||
            result = False
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(e)
 | 
			
		||||
            self.url.errors.append('Invalid template syntax')
 | 
			
		||||
            result = False
 | 
			
		||||
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
class SingleExtraProxy(Form):
 | 
			
		||||
@@ -575,7 +586,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
 | 
			
		||||
    empty_pages_are_a_change =  BooleanField('Treat empty pages as a change?', default=False)
 | 
			
		||||
    fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
 | 
			
		||||
    global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()])
 | 
			
		||||
    global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)])
 | 
			
		||||
    global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)])
 | 
			
		||||
    ignore_whitespace = BooleanField('Ignore whitespace')
 | 
			
		||||
    password = SaltyPasswordField()
 | 
			
		||||
    pager_size = IntegerField('Pager size',
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,13 @@
 | 
			
		||||
 | 
			
		||||
from bs4 import BeautifulSoup
 | 
			
		||||
from inscriptis import get_text
 | 
			
		||||
from jsonpath_ng.ext import parse
 | 
			
		||||
from typing import List
 | 
			
		||||
from inscriptis.model.config import ParserConfig
 | 
			
		||||
from xml.sax.saxutils import escape as xml_escape
 | 
			
		||||
from lxml import etree
 | 
			
		||||
import json
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis
 | 
			
		||||
TEXT_FILTER_LIST_LINE_SUFFIX = "<br>"
 | 
			
		||||
 | 
			
		||||
TRANSLATE_WHITESPACE_TABLE = str.maketrans('', '', '\r\n\t ')
 | 
			
		||||
PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$'
 | 
			
		||||
 | 
			
		||||
# 'price' , 'lowPrice', 'highPrice' are usually under here
 | 
			
		||||
# All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here
 | 
			
		||||
LD_JSON_PRODUCT_OFFER_SELECTORS = ["json:$..offers", "json:$..Offers"]
 | 
			
		||||
@@ -39,6 +34,7 @@ def perl_style_slash_enclosed_regex_to_options(regex):
 | 
			
		||||
 | 
			
		||||
# Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches
 | 
			
		||||
def include_filters(include_filters, html_content, append_pretty_line_formatting=False):
 | 
			
		||||
    from bs4 import BeautifulSoup
 | 
			
		||||
    soup = BeautifulSoup(html_content, "html.parser")
 | 
			
		||||
    html_block = ""
 | 
			
		||||
    r = soup.select(include_filters, separator="")
 | 
			
		||||
@@ -56,16 +52,32 @@ def include_filters(include_filters, html_content, append_pretty_line_formatting
 | 
			
		||||
    return html_block
 | 
			
		||||
 | 
			
		||||
def subtractive_css_selector(css_selector, html_content):
 | 
			
		||||
    from bs4 import BeautifulSoup
 | 
			
		||||
    soup = BeautifulSoup(html_content, "html.parser")
 | 
			
		||||
    for item in soup.select(css_selector):
 | 
			
		||||
        item.decompose()
 | 
			
		||||
    return str(soup)
 | 
			
		||||
 | 
			
		||||
def subtractive_xpath_selector(xpath_selector, html_content): 
 | 
			
		||||
    html_tree = etree.HTML(html_content)
 | 
			
		||||
    elements_to_remove = html_tree.xpath(xpath_selector)
 | 
			
		||||
 | 
			
		||||
    for element in elements_to_remove:
 | 
			
		||||
        element.getparent().remove(element)
 | 
			
		||||
 | 
			
		||||
    modified_html = etree.tostring(html_tree, method="html").decode("utf-8")
 | 
			
		||||
    return modified_html
 | 
			
		||||
 | 
			
		||||
def element_removal(selectors: List[str], html_content):
 | 
			
		||||
    """Joins individual filters into one css filter."""
 | 
			
		||||
    selector = ",".join(selectors)
 | 
			
		||||
    return subtractive_css_selector(selector, html_content)
 | 
			
		||||
    """Removes elements that match a list of CSS or xPath selectors."""
 | 
			
		||||
    modified_html = html_content
 | 
			
		||||
    for selector in selectors:
 | 
			
		||||
        if selector.startswith(('xpath:', 'xpath1:', '//')):
 | 
			
		||||
            xpath_selector = selector.removeprefix('xpath:').removeprefix('xpath1:')
 | 
			
		||||
            modified_html = subtractive_xpath_selector(xpath_selector, modified_html)
 | 
			
		||||
        else:
 | 
			
		||||
            modified_html = subtractive_css_selector(selector, modified_html)
 | 
			
		||||
    return modified_html
 | 
			
		||||
 | 
			
		||||
def elementpath_tostring(obj):
 | 
			
		||||
    """
 | 
			
		||||
@@ -181,6 +193,7 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals
 | 
			
		||||
 | 
			
		||||
# Extract/find element
 | 
			
		||||
def extract_element(find='title', html_content=''):
 | 
			
		||||
    from bs4 import BeautifulSoup
 | 
			
		||||
 | 
			
		||||
    #Re #106, be sure to handle when its not found
 | 
			
		||||
    element_text = None
 | 
			
		||||
@@ -194,6 +207,8 @@ def extract_element(find='title', html_content=''):
 | 
			
		||||
 | 
			
		||||
#
 | 
			
		||||
def _parse_json(json_data, json_filter):
 | 
			
		||||
    from jsonpath_ng.ext import parse
 | 
			
		||||
 | 
			
		||||
    if json_filter.startswith("json:"):
 | 
			
		||||
        jsonpath_expression = parse(json_filter.replace('json:', ''))
 | 
			
		||||
        match = jsonpath_expression.find(json_data)
 | 
			
		||||
@@ -242,6 +257,8 @@ def _get_stripped_text_from_json_match(match):
 | 
			
		||||
# json_filter - ie json:$..price
 | 
			
		||||
# ensure_is_ldjson_info_type - str "product", optional, "@type == product" (I dont know how to do that as a json selector)
 | 
			
		||||
def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None):
 | 
			
		||||
    from bs4 import BeautifulSoup
 | 
			
		||||
 | 
			
		||||
    stripped_text_from_html = False
 | 
			
		||||
# https://github.com/dgtlmoon/changedetection.io/pull/2041#issuecomment-1848397161w
 | 
			
		||||
    # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags
 | 
			
		||||
@@ -309,6 +326,7 @@ def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None
 | 
			
		||||
#          - "line numbers" return a list of line numbers that match (int list)
 | 
			
		||||
#
 | 
			
		||||
# wordlist - list of regex's (str) or words (str)
 | 
			
		||||
# Preserves all linefeeds and other whitespacing, its not the job of this to remove that
 | 
			
		||||
def strip_ignore_text(content, wordlist, mode="content"):
 | 
			
		||||
    i = 0
 | 
			
		||||
    output = []
 | 
			
		||||
@@ -324,34 +342,33 @@ def strip_ignore_text(content, wordlist, mode="content"):
 | 
			
		||||
        else:
 | 
			
		||||
            ignore_text.append(k.strip())
 | 
			
		||||
 | 
			
		||||
    for line in content.splitlines():
 | 
			
		||||
    for line in content.splitlines(keepends=True):
 | 
			
		||||
        i += 1
 | 
			
		||||
        # Always ignore blank lines in this mode. (when this function gets called)
 | 
			
		||||
        got_match = False
 | 
			
		||||
        if len(line.strip()):
 | 
			
		||||
            for l in ignore_text:
 | 
			
		||||
                if l.lower() in line.lower():
 | 
			
		||||
        for l in ignore_text:
 | 
			
		||||
            if l.lower() in line.lower():
 | 
			
		||||
                got_match = True
 | 
			
		||||
 | 
			
		||||
        if not got_match:
 | 
			
		||||
            for r in ignore_regex:
 | 
			
		||||
                if r.search(line):
 | 
			
		||||
                    got_match = True
 | 
			
		||||
 | 
			
		||||
            if not got_match:
 | 
			
		||||
                for r in ignore_regex:
 | 
			
		||||
                    if r.search(line):
 | 
			
		||||
                        got_match = True
 | 
			
		||||
 | 
			
		||||
            if not got_match:
 | 
			
		||||
                # Not ignored
 | 
			
		||||
                output.append(line.encode('utf8'))
 | 
			
		||||
            else:
 | 
			
		||||
                ignored_line_numbers.append(i)
 | 
			
		||||
 | 
			
		||||
        if not got_match:
 | 
			
		||||
            # Not ignored, and should preserve "keepends"
 | 
			
		||||
            output.append(line)
 | 
			
		||||
        else:
 | 
			
		||||
            ignored_line_numbers.append(i)
 | 
			
		||||
 | 
			
		||||
    # Used for finding out what to highlight
 | 
			
		||||
    if mode == "line numbers":
 | 
			
		||||
        return ignored_line_numbers
 | 
			
		||||
 | 
			
		||||
    return "\n".encode('utf8').join(output)
 | 
			
		||||
    return ''.join(output)
 | 
			
		||||
 | 
			
		||||
def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str:
 | 
			
		||||
    from xml.sax.saxutils import escape as xml_escape
 | 
			
		||||
    pattern = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\s*)*)\]\]>'
 | 
			
		||||
    def repl(m):
 | 
			
		||||
        text = m.group(1)
 | 
			
		||||
@@ -360,6 +377,9 @@ def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False
 | 
			
		||||
    return re.sub(pattern, repl, html_content)
 | 
			
		||||
 | 
			
		||||
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False) -> str:
 | 
			
		||||
    from inscriptis import get_text
 | 
			
		||||
    from inscriptis.model.config import ParserConfig
 | 
			
		||||
 | 
			
		||||
    """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
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,8 @@ import re
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from loguru import logger
 | 
			
		||||
 | 
			
		||||
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
 | 
			
		||||
 | 
			
		||||
# Allowable protocols, protects against javascript: etc
 | 
			
		||||
# file:// is further checked by ALLOW_FILE_URI
 | 
			
		||||
SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):'
 | 
			
		||||
@@ -36,8 +38,9 @@ class model(watch_base):
 | 
			
		||||
    jitter_seconds = 0
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *arg, **kw):
 | 
			
		||||
        self.__datastore_path = kw['datastore_path']
 | 
			
		||||
        del kw['datastore_path']
 | 
			
		||||
        self.__datastore_path = kw.get('datastore_path')
 | 
			
		||||
        if kw.get('datastore_path'):
 | 
			
		||||
            del kw['datastore_path']
 | 
			
		||||
        super(model, self).__init__(*arg, **kw)
 | 
			
		||||
        if kw.get('default'):
 | 
			
		||||
            self.update(kw['default'])
 | 
			
		||||
@@ -171,6 +174,10 @@ class model(watch_base):
 | 
			
		||||
        """
 | 
			
		||||
        tmp_history = {}
 | 
			
		||||
 | 
			
		||||
        # In the case we are only using the watch for processing without history
 | 
			
		||||
        if not self.watch_data_dir:
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
        # Read the history file as a dict
 | 
			
		||||
        fname = os.path.join(self.watch_data_dir, "history.txt")
 | 
			
		||||
        if os.path.isfile(fname):
 | 
			
		||||
@@ -307,13 +314,13 @@ class model(watch_base):
 | 
			
		||||
            dest = os.path.join(self.watch_data_dir, snapshot_fname)
 | 
			
		||||
            if not os.path.exists(dest):
 | 
			
		||||
                with open(dest, 'wb') as f:
 | 
			
		||||
                    f.write(brotli.compress(contents, mode=brotli.MODE_TEXT))
 | 
			
		||||
                    f.write(brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT))
 | 
			
		||||
        else:
 | 
			
		||||
            snapshot_fname = f"{snapshot_id}.txt"
 | 
			
		||||
            dest = os.path.join(self.watch_data_dir, snapshot_fname)
 | 
			
		||||
            if not os.path.exists(dest):
 | 
			
		||||
                with open(dest, 'wb') as f:
 | 
			
		||||
                    f.write(contents)
 | 
			
		||||
                    f.write(contents.encode('utf-8'))
 | 
			
		||||
 | 
			
		||||
        # Append to index
 | 
			
		||||
        # @todo check last char was \n
 | 
			
		||||
@@ -345,14 +352,32 @@ class model(watch_base):
 | 
			
		||||
        return seconds
 | 
			
		||||
 | 
			
		||||
    # Iterate over all history texts and see if something new exists
 | 
			
		||||
    def lines_contain_something_unique_compared_to_history(self, lines: list):
 | 
			
		||||
        local_lines = set([l.decode('utf-8').strip().lower() for l in lines])
 | 
			
		||||
    # Always applying .strip() to start/end but optionally replace any other whitespace
 | 
			
		||||
    def lines_contain_something_unique_compared_to_history(self, lines: list, ignore_whitespace=False):
 | 
			
		||||
        local_lines = []
 | 
			
		||||
        if lines:
 | 
			
		||||
            if ignore_whitespace:
 | 
			
		||||
                if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk
 | 
			
		||||
                    local_lines = set([l.translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines])
 | 
			
		||||
                else:
 | 
			
		||||
                    local_lines = set([l.decode('utf-8').translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines])
 | 
			
		||||
            else:
 | 
			
		||||
                if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk
 | 
			
		||||
                    local_lines = set([l.strip().lower() for l in lines])
 | 
			
		||||
                else:
 | 
			
		||||
                    local_lines = set([l.decode('utf-8').strip().lower() for l in lines])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        # Compare each lines (set) against each history text file (set) looking for something new..
 | 
			
		||||
        existing_history = set({})
 | 
			
		||||
        for k, v in self.history.items():
 | 
			
		||||
            content = self.get_history_snapshot(k)
 | 
			
		||||
            alist = set([line.strip().lower() for line in content.splitlines()])
 | 
			
		||||
 | 
			
		||||
            if ignore_whitespace:
 | 
			
		||||
                alist = set([line.translate(TRANSLATE_WHITESPACE_TABLE).lower() for line in content.splitlines()])
 | 
			
		||||
            else:
 | 
			
		||||
                alist = set([line.strip().lower() for line in content.splitlines()])
 | 
			
		||||
 | 
			
		||||
            existing_history = existing_history.union(alist)
 | 
			
		||||
 | 
			
		||||
        # Check that everything in local_lines(new stuff) already exists in existing_history - it should
 | 
			
		||||
@@ -396,8 +421,8 @@ class model(watch_base):
 | 
			
		||||
    @property
 | 
			
		||||
    def watch_data_dir(self):
 | 
			
		||||
        # The base dir of the watch data
 | 
			
		||||
        return os.path.join(self.__datastore_path, self['uuid'])
 | 
			
		||||
    
 | 
			
		||||
        return os.path.join(self.__datastore_path, self['uuid']) if self.__datastore_path else None
 | 
			
		||||
 | 
			
		||||
    def get_error_text(self):
 | 
			
		||||
        """Return the text saved from a previous request that resulted in a non-200 error"""
 | 
			
		||||
        fname = os.path.join(self.watch_data_dir, "last-error.txt")
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ class watch_base(dict):
 | 
			
		||||
            'check_count': 0,
 | 
			
		||||
            'check_unique_lines': False,  # On change-detected, compare against all history if its something new
 | 
			
		||||
            'consecutive_filter_failures': 0,  # Every time the CSS/xPath filter cannot be located, reset when all is fine.
 | 
			
		||||
            'content-type': None,
 | 
			
		||||
            'date_created': None,
 | 
			
		||||
            'extract_text': [],  # Extract text by regex after filters
 | 
			
		||||
            'extract_title_as_title': False,
 | 
			
		||||
@@ -60,6 +61,8 @@ class watch_base(dict):
 | 
			
		||||
            'time_between_check_use_default': True,
 | 
			
		||||
            'title': None,
 | 
			
		||||
            'track_ldjson_price_data': None,
 | 
			
		||||
            'trim_text_whitespace': False,
 | 
			
		||||
            'remove_duplicate_lines': False,
 | 
			
		||||
            'trigger_text': [],  # List of text or regex to wait for until a change is detected
 | 
			
		||||
            'url': '',
 | 
			
		||||
            'uuid': str(uuid.uuid4()),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,10 @@
 | 
			
		||||
import apprise
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from apprise import NotifyFormat
 | 
			
		||||
import json
 | 
			
		||||
import apprise
 | 
			
		||||
from loguru import logger
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
valid_tokens = {
 | 
			
		||||
    'base_url': '',
 | 
			
		||||
    'current_snapshot': '',
 | 
			
		||||
@@ -34,86 +35,11 @@ valid_notification_formats = {
 | 
			
		||||
    default_notification_format_for_watch: default_notification_format_for_watch
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# include the decorator
 | 
			
		||||
from apprise.decorators import notify
 | 
			
		||||
 | 
			
		||||
@notify(on="delete")
 | 
			
		||||
@notify(on="deletes")
 | 
			
		||||
@notify(on="get")
 | 
			
		||||
@notify(on="gets")
 | 
			
		||||
@notify(on="post")
 | 
			
		||||
@notify(on="posts")
 | 
			
		||||
@notify(on="put")
 | 
			
		||||
@notify(on="puts")
 | 
			
		||||
def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
 | 
			
		||||
    import requests
 | 
			
		||||
    from apprise.utils import parse_url as apprise_parse_url
 | 
			
		||||
    from apprise import URLBase
 | 
			
		||||
 | 
			
		||||
    url = kwargs['meta'].get('url')
 | 
			
		||||
 | 
			
		||||
    if url.startswith('post'):
 | 
			
		||||
        r = requests.post
 | 
			
		||||
    elif url.startswith('get'):
 | 
			
		||||
        r = requests.get
 | 
			
		||||
    elif url.startswith('put'):
 | 
			
		||||
        r = requests.put
 | 
			
		||||
    elif url.startswith('delete'):
 | 
			
		||||
        r = requests.delete
 | 
			
		||||
 | 
			
		||||
    url = url.replace('post://', 'http://')
 | 
			
		||||
    url = url.replace('posts://', 'https://')
 | 
			
		||||
    url = url.replace('put://', 'http://')
 | 
			
		||||
    url = url.replace('puts://', 'https://')
 | 
			
		||||
    url = url.replace('get://', 'http://')
 | 
			
		||||
    url = url.replace('gets://', 'https://')
 | 
			
		||||
    url = url.replace('put://', 'http://')
 | 
			
		||||
    url = url.replace('puts://', 'https://')
 | 
			
		||||
    url = url.replace('delete://', 'http://')
 | 
			
		||||
    url = url.replace('deletes://', 'https://')
 | 
			
		||||
 | 
			
		||||
    headers = {}
 | 
			
		||||
    params = {}
 | 
			
		||||
    auth = None
 | 
			
		||||
 | 
			
		||||
    # Convert /foobar?+some-header=hello to proper header dictionary
 | 
			
		||||
    results = apprise_parse_url(url)
 | 
			
		||||
    if results:
 | 
			
		||||
        # Add our headers that the user can potentially over-ride if they wish
 | 
			
		||||
        # to to our returned result set and tidy entries by unquoting them
 | 
			
		||||
        headers = {URLBase.unquote(x): URLBase.unquote(y)
 | 
			
		||||
                   for x, y in results['qsd+'].items()}
 | 
			
		||||
 | 
			
		||||
        # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
 | 
			
		||||
        # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
 | 
			
		||||
        # but here we are making straight requests, so we need todo convert this against apprise's logic
 | 
			
		||||
        for k, v in results['qsd'].items():
 | 
			
		||||
            if not k.strip('+-') in results['qsd+'].keys():
 | 
			
		||||
                params[URLBase.unquote(k)] = URLBase.unquote(v)
 | 
			
		||||
 | 
			
		||||
        # Determine Authentication
 | 
			
		||||
        auth = ''
 | 
			
		||||
        if results.get('user') and results.get('password'):
 | 
			
		||||
            auth = (URLBase.unquote(results.get('user')), URLBase.unquote(results.get('user')))
 | 
			
		||||
        elif results.get('user'):
 | 
			
		||||
            auth = (URLBase.unquote(results.get('user')))
 | 
			
		||||
 | 
			
		||||
    # Try to auto-guess if it's JSON
 | 
			
		||||
    try:
 | 
			
		||||
        json.loads(body)
 | 
			
		||||
        headers['Content-Type'] = 'application/json; charset=utf-8'
 | 
			
		||||
    except ValueError as e:
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    r(results.get('url'),
 | 
			
		||||
      auth=auth,
 | 
			
		||||
      data=body,
 | 
			
		||||
      headers=headers,
 | 
			
		||||
      params=params
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_notification(n_object, datastore):
 | 
			
		||||
    # so that the custom endpoints are registered
 | 
			
		||||
    from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
 | 
			
		||||
 | 
			
		||||
    from .safe_jinja import render as jinja_render
 | 
			
		||||
    now = time.time()
 | 
			
		||||
@@ -157,7 +83,7 @@ def process_notification(n_object, datastore):
 | 
			
		||||
                logger.warning(f"Process Notification: skipping empty notification URL.")
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            logger.info(">> Process Notification: AppRise notifying {}".format(url))
 | 
			
		||||
            logger.info(f">> Process Notification: AppRise notifying {url}")
 | 
			
		||||
            url = jinja_render(template_str=url, **notification_parameters)
 | 
			
		||||
 | 
			
		||||
            # Re 323 - Limit discord length to their 2000 char limit total or it wont send.
 | 
			
		||||
@@ -230,6 +156,7 @@ def process_notification(n_object, datastore):
 | 
			
		||||
        log_value = logs.getvalue()
 | 
			
		||||
 | 
			
		||||
        if log_value and 'WARNING' in log_value or 'ERROR' in log_value:
 | 
			
		||||
            logger.critical(log_value)
 | 
			
		||||
            raise Exception(log_value)
 | 
			
		||||
 | 
			
		||||
    # Return what was sent for better logging - after the for loop
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,14 @@
 | 
			
		||||
from abc import abstractmethod
 | 
			
		||||
from changedetectionio.content_fetchers.base import Fetcher
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
 | 
			
		||||
from copy import deepcopy
 | 
			
		||||
from loguru import logger
 | 
			
		||||
import hashlib
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import importlib
 | 
			
		||||
import pkgutil
 | 
			
		||||
import inspect
 | 
			
		||||
import os
 | 
			
		||||
import pkgutil
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
class difference_detection_processor():
 | 
			
		||||
 | 
			
		||||
@@ -18,14 +18,19 @@ class difference_detection_processor():
 | 
			
		||||
    screenshot = None
 | 
			
		||||
    watch = None
 | 
			
		||||
    xpath_data = None
 | 
			
		||||
    preferred_proxy = None
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, datastore, watch_uuid, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.datastore = datastore
 | 
			
		||||
        self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))
 | 
			
		||||
        # Generic fetcher that should be extended (requests, playwright etc)
 | 
			
		||||
        self.fetcher = Fetcher()
 | 
			
		||||
 | 
			
		||||
    def call_browser(self, preferred_proxy_id=None):
 | 
			
		||||
 | 
			
		||||
    def call_browser(self):
 | 
			
		||||
        from requests.structures import CaseInsensitiveDict
 | 
			
		||||
 | 
			
		||||
        # Protect against file:// access
 | 
			
		||||
        if re.search(r'^file://', self.watch.get('url', '').strip(), re.IGNORECASE):
 | 
			
		||||
            if not strtobool(os.getenv('ALLOW_FILE_URI', 'false')):
 | 
			
		||||
@@ -39,7 +44,7 @@ class difference_detection_processor():
 | 
			
		||||
        prefer_fetch_backend = self.watch.get('fetch_backend', 'system')
 | 
			
		||||
 | 
			
		||||
        # Proxy ID "key"
 | 
			
		||||
        preferred_proxy_id = self.datastore.get_preferred_proxy_for_watch(uuid=self.watch.get('uuid'))
 | 
			
		||||
        preferred_proxy_id = preferred_proxy_id if preferred_proxy_id else self.datastore.get_preferred_proxy_for_watch(uuid=self.watch.get('uuid'))
 | 
			
		||||
 | 
			
		||||
        # Pluggable content self.fetcher
 | 
			
		||||
        if not prefer_fetch_backend or prefer_fetch_backend == 'system':
 | 
			
		||||
@@ -133,8 +138,18 @@ class difference_detection_processor():
 | 
			
		||||
        is_binary = self.watch.is_pdf
 | 
			
		||||
 | 
			
		||||
        # And here we go! call the right browser with browser-specific settings
 | 
			
		||||
        self.fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_codes, self.watch.get('include_filters'),
 | 
			
		||||
                    is_binary=is_binary)
 | 
			
		||||
        empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False)
 | 
			
		||||
 | 
			
		||||
        self.fetcher.run(url=url,
 | 
			
		||||
                         timeout=timeout,
 | 
			
		||||
                         request_headers=request_headers,
 | 
			
		||||
                         request_body=request_body,
 | 
			
		||||
                         request_method=request_method,
 | 
			
		||||
                         ignore_status_codes=ignore_status_codes,
 | 
			
		||||
                         current_include_filters=self.watch.get('include_filters'),
 | 
			
		||||
                         is_binary=is_binary,
 | 
			
		||||
                         empty_pages_are_a_change=empty_pages_are_a_change
 | 
			
		||||
                         )
 | 
			
		||||
 | 
			
		||||
        #@todo .quit here could go on close object, so we can run JS if change-detected
 | 
			
		||||
        self.fetcher.quit()
 | 
			
		||||
@@ -142,7 +157,7 @@ class difference_detection_processor():
 | 
			
		||||
        # After init, call run_changedetection() which will do the actual change-detection
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def run_changedetection(self, watch, skip_when_checksum_same=True):
 | 
			
		||||
    def run_changedetection(self, watch):
 | 
			
		||||
        update_obj = {'last_notification_error': False, 'last_error': False}
 | 
			
		||||
        some_data = 'xxxxx'
 | 
			
		||||
        update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,12 @@
 | 
			
		||||
 | 
			
		||||
from changedetectionio.model.Watch import model as BaseWatch
 | 
			
		||||
import re
 | 
			
		||||
from babel.numbers import parse_decimal
 | 
			
		||||
from changedetectionio.model.Watch import model as BaseWatch
 | 
			
		||||
from typing import Union
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
class Restock(dict):
 | 
			
		||||
 | 
			
		||||
    def parse_currency(self, raw_value: str) -> float:
 | 
			
		||||
    def parse_currency(self, raw_value: str) -> Union[float, None]:
 | 
			
		||||
        # Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer.
 | 
			
		||||
        standardized_value = raw_value
 | 
			
		||||
 | 
			
		||||
@@ -21,8 +22,11 @@ class Restock(dict):
 | 
			
		||||
        # Remove any non-numeric characters except for the decimal point
 | 
			
		||||
        standardized_value = re.sub(r'[^\d.-]', '', standardized_value)
 | 
			
		||||
 | 
			
		||||
        # Convert to float
 | 
			
		||||
        return float(parse_decimal(standardized_value, locale='en'))
 | 
			
		||||
        if standardized_value:
 | 
			
		||||
            # Convert to float
 | 
			
		||||
            return float(parse_decimal(standardized_value, locale='en'))
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        # Define default values
 | 
			
		||||
@@ -45,13 +49,10 @@ class Restock(dict):
 | 
			
		||||
 | 
			
		||||
    def __setitem__(self, key, value):
 | 
			
		||||
        # Custom logic to handle setting price and original_price
 | 
			
		||||
        if key == 'price':
 | 
			
		||||
        if key == 'price' or key == 'original_price':
 | 
			
		||||
            if isinstance(value, str):
 | 
			
		||||
                value = self.parse_currency(raw_value=value)
 | 
			
		||||
 | 
			
		||||
            if value and not self.get('original_price'):
 | 
			
		||||
                self['original_price'] = value
 | 
			
		||||
 | 
			
		||||
        super().__setitem__(key, value)
 | 
			
		||||
 | 
			
		||||
class Watch(BaseWatch):
 | 
			
		||||
 
 | 
			
		||||
@@ -2,8 +2,7 @@ from .. import difference_detection_processor
 | 
			
		||||
from ..exceptions import ProcessorException
 | 
			
		||||
from . import Restock
 | 
			
		||||
from loguru import logger
 | 
			
		||||
import hashlib
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
import urllib3
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
@@ -27,6 +26,30 @@ def _search_prop_by_value(matches, value):
 | 
			
		||||
            if value in prop[0]:
 | 
			
		||||
                return prop[1]  # Yield the desired value and exit the function
 | 
			
		||||
 | 
			
		||||
def _deduplicate_prices(data):
 | 
			
		||||
    import re
 | 
			
		||||
 | 
			
		||||
    '''
 | 
			
		||||
    Some price data has multiple entries, OR it has a single entry with ['$159', '159', 159, "$ 159"] or just "159"
 | 
			
		||||
    Get all the values, clean it and add it to a set then return the unique values
 | 
			
		||||
    '''
 | 
			
		||||
    unique_data = set()
 | 
			
		||||
 | 
			
		||||
    # Return the complete 'datum' where its price was not seen before
 | 
			
		||||
    for datum in data:
 | 
			
		||||
 | 
			
		||||
        if isinstance(datum.value, list):
 | 
			
		||||
            # Process each item in the list
 | 
			
		||||
            normalized_value = set([float(re.sub(r'[^\d.]', '', str(item))) for item in datum.value])
 | 
			
		||||
            unique_data.update(normalized_value)
 | 
			
		||||
        else:
 | 
			
		||||
            # Process single value
 | 
			
		||||
            v = float(re.sub(r'[^\d.]', '', str(datum.value)))
 | 
			
		||||
            unique_data.add(v)
 | 
			
		||||
 | 
			
		||||
    return list(unique_data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# should return Restock()
 | 
			
		||||
# add casting?
 | 
			
		||||
def get_itemprop_availability(html_content) -> Restock:
 | 
			
		||||
@@ -36,17 +59,21 @@ def get_itemprop_availability(html_content) -> Restock:
 | 
			
		||||
    """
 | 
			
		||||
    from jsonpath_ng import parse
 | 
			
		||||
 | 
			
		||||
    import re
 | 
			
		||||
    now = time.time()
 | 
			
		||||
    import extruct
 | 
			
		||||
    logger.trace(f"Imported extruct module in {time.time() - now:.3f}s")
 | 
			
		||||
 | 
			
		||||
    value = {}
 | 
			
		||||
    now = time.time()
 | 
			
		||||
 | 
			
		||||
    # Extruct is very slow, I'm wondering if some ML is going to be faster (800ms on my i7), 'rdfa' seems to be the heaviest.
 | 
			
		||||
 | 
			
		||||
    syntaxes = ['dublincore', 'json-ld', 'microdata', 'microformat', 'opengraph']
 | 
			
		||||
    try:
 | 
			
		||||
        data = extruct.extract(html_content, syntaxes=syntaxes)
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.warning(f"Unable to extract data, document parsing with extruct failed with {type(e).__name__} - {str(e)}")
 | 
			
		||||
        return Restock()
 | 
			
		||||
 | 
			
		||||
    data = extruct.extract(html_content, syntaxes=syntaxes)
 | 
			
		||||
    logger.trace(f"Extruct basic extract of all metadata done in {time.time() - now:.3f}s")
 | 
			
		||||
 | 
			
		||||
    # First phase, dead simple scanning of anything that looks useful
 | 
			
		||||
@@ -57,18 +84,17 @@ def get_itemprop_availability(html_content) -> Restock:
 | 
			
		||||
        pricecurrency_parse = parse('$..(pricecurrency|currency|priceCurrency )')
 | 
			
		||||
        availability_parse = parse('$..(availability|Availability)')
 | 
			
		||||
 | 
			
		||||
        price_result = price_parse.find(data)
 | 
			
		||||
        price_result = _deduplicate_prices(price_parse.find(data))
 | 
			
		||||
        if price_result:
 | 
			
		||||
            # Right now, we just support single product items, maybe we will store the whole actual metadata seperately in teh future and
 | 
			
		||||
            # parse that for the UI?
 | 
			
		||||
            prices_found = set(str(item.value).replace('$', '') for item in price_result)
 | 
			
		||||
            if len(price_result) > 1 and len(prices_found) > 1:
 | 
			
		||||
            if len(price_result) > 1 and len(price_result) > 1:
 | 
			
		||||
                # See of all prices are different, in the case that one product has many embedded data types with the same price
 | 
			
		||||
                # One might have $121.95 and another 121.95 etc
 | 
			
		||||
                logger.warning(f"More than one price found {prices_found}, throwing exception, cant use this plugin.")
 | 
			
		||||
                logger.warning(f"More than one price found {price_result}, throwing exception, cant use this plugin.")
 | 
			
		||||
                raise MoreThanOnePriceFound()
 | 
			
		||||
 | 
			
		||||
            value['price'] = price_result[0].value
 | 
			
		||||
            value['price'] = price_result[0]
 | 
			
		||||
 | 
			
		||||
        pricecurrency_result = pricecurrency_parse.find(data)
 | 
			
		||||
        if pricecurrency_result:
 | 
			
		||||
@@ -118,7 +144,9 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
    screenshot = None
 | 
			
		||||
    xpath_data = None
 | 
			
		||||
 | 
			
		||||
    def run_changedetection(self, watch, skip_when_checksum_same=True):
 | 
			
		||||
    def run_changedetection(self, watch):
 | 
			
		||||
        import hashlib
 | 
			
		||||
 | 
			
		||||
        if not watch:
 | 
			
		||||
            raise Exception("Watch no longer exists.")
 | 
			
		||||
 | 
			
		||||
@@ -132,6 +160,20 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
        update_obj['content_type'] = self.fetcher.headers.get('Content-Type', '')
 | 
			
		||||
        update_obj["last_check_status"] = self.fetcher.get_last_status_code()
 | 
			
		||||
 | 
			
		||||
        # Only try to process restock information (like scraping for keywords) if the page was actually rendered correctly.
 | 
			
		||||
        # Otherwise it will assume "in stock" because nothing suggesting the opposite was found
 | 
			
		||||
        from ...html_tools import html_to_text
 | 
			
		||||
        text = html_to_text(self.fetcher.content)
 | 
			
		||||
        logger.debug(f"Length of text after conversion: {len(text)}")
 | 
			
		||||
        if not len(text):
 | 
			
		||||
            from ...content_fetchers.exceptions import ReplyWithContentButNoText
 | 
			
		||||
            raise ReplyWithContentButNoText(url=watch.link,
 | 
			
		||||
                                            status_code=self.fetcher.get_last_status_code(),
 | 
			
		||||
                                            screenshot=self.fetcher.screenshot,
 | 
			
		||||
                                            html_content=self.fetcher.content,
 | 
			
		||||
                                            xpath_data=self.fetcher.xpath_data
 | 
			
		||||
                                            )
 | 
			
		||||
 | 
			
		||||
        # Which restock settings to compare against?
 | 
			
		||||
        restock_settings = watch.get('restock_settings', {})
 | 
			
		||||
 | 
			
		||||
@@ -146,7 +188,7 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
 | 
			
		||||
        itemprop_availability = {}
 | 
			
		||||
        try:
 | 
			
		||||
            itemprop_availability = get_itemprop_availability(html_content=self.fetcher.content)
 | 
			
		||||
            itemprop_availability = get_itemprop_availability(self.fetcher.content)
 | 
			
		||||
        except MoreThanOnePriceFound as e:
 | 
			
		||||
            # Add the real data
 | 
			
		||||
            raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
 | 
			
		||||
@@ -177,7 +219,12 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
        # Main detection method
 | 
			
		||||
        fetched_md5 = None
 | 
			
		||||
 | 
			
		||||
        if not self.fetcher.instock_data and not itemprop_availability.get('availability'):
 | 
			
		||||
        # store original price if not set
 | 
			
		||||
        if itemprop_availability and itemprop_availability.get('price') and not itemprop_availability.get('original_price'):
 | 
			
		||||
            itemprop_availability['original_price'] = itemprop_availability.get('price')
 | 
			
		||||
            update_obj['restock']["original_price"] = itemprop_availability.get('price')
 | 
			
		||||
 | 
			
		||||
        if not self.fetcher.instock_data and not itemprop_availability.get('availability') and not itemprop_availability.get('price'):
 | 
			
		||||
            raise ProcessorException(
 | 
			
		||||
                message=f"Unable to extract restock data for this page unfortunately. (Got code {self.fetcher.get_last_status_code()} from server), no embedded stock information was found and nothing interesting in the text, try using this watch with Chrome.",
 | 
			
		||||
                url=watch.get('url'),
 | 
			
		||||
@@ -186,16 +233,25 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
                xpath_data=self.fetcher.xpath_data
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        logger.debug(f"self.fetcher.instock_data is - '{self.fetcher.instock_data}' and itemprop_availability.get('availability') is {itemprop_availability.get('availability')}")
 | 
			
		||||
        # Nothing automatic in microdata found, revert to scraping the page
 | 
			
		||||
        if self.fetcher.instock_data and itemprop_availability.get('availability') is None:
 | 
			
		||||
            # 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.
 | 
			
		||||
            # Careful! this does not really come from chrome/js when the watch is set to plaintext
 | 
			
		||||
            update_obj['restock']["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False
 | 
			
		||||
            logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned '{self.fetcher.instock_data}' from JS scraper.")
 | 
			
		||||
            logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned instock_data - '{self.fetcher.instock_data}' from JS scraper.")
 | 
			
		||||
 | 
			
		||||
        # Very often websites will lie about the 'availability' in the metadata, so if the scraped version says its NOT in stock, use that.
 | 
			
		||||
        if self.fetcher.instock_data and self.fetcher.instock_data != 'Possibly in stock':
 | 
			
		||||
            if update_obj['restock'].get('in_stock'):
 | 
			
		||||
                logger.warning(
 | 
			
		||||
                    f"Lie detected in the availability machine data!! when scraping said its not in stock!! itemprop was '{itemprop_availability}' and scraped from browser was '{self.fetcher.instock_data}' update obj was {update_obj['restock']} ")
 | 
			
		||||
                logger.warning(f"Setting instock to FALSE, scraper found '{self.fetcher.instock_data}' in the body but metadata reported not-in-stock")
 | 
			
		||||
                update_obj['restock']["in_stock"] = False
 | 
			
		||||
 | 
			
		||||
        # What we store in the snapshot
 | 
			
		||||
        price = update_obj.get('restock').get('price') if update_obj.get('restock').get('price') else ""
 | 
			
		||||
        snapshot_content = f"{update_obj.get('restock').get('in_stock')} - {price}"
 | 
			
		||||
        snapshot_content = f"In Stock: {update_obj.get('restock').get('in_stock')} - Price: {price}"
 | 
			
		||||
 | 
			
		||||
        # Main detection method
 | 
			
		||||
        fetched_md5 = hashlib.md5(snapshot_content.encode('utf-8')).hexdigest()
 | 
			
		||||
@@ -255,4 +311,4 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
        # Always record the new checksum
 | 
			
		||||
        update_obj["previous_md5"] = fetched_md5
 | 
			
		||||
 | 
			
		||||
        return changed_detected, update_obj, snapshot_content.encode('utf-8').strip()
 | 
			
		||||
        return changed_detected, update_obj, snapshot_content.strip()
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,115 @@
 | 
			
		||||
 | 
			
		||||
from loguru import logger
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _task(watch, update_handler):
 | 
			
		||||
    from changedetectionio.content_fetchers.exceptions import ReplyWithContentButNoText
 | 
			
		||||
    from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
 | 
			
		||||
 | 
			
		||||
    text_after_filter = ''
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        # The slow process (we run 2 of these in parallel)
 | 
			
		||||
        changed_detected, update_obj, text_after_filter = update_handler.run_changedetection(watch=watch)
 | 
			
		||||
    except FilterNotFoundInResponse as e:
 | 
			
		||||
        text_after_filter = f"Filter not found in HTML: {str(e)}"
 | 
			
		||||
    except ReplyWithContentButNoText as e:
 | 
			
		||||
        text_after_filter = f"Filter found but no text (empty result)"
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        text_after_filter = f"Error: {str(e)}"
 | 
			
		||||
 | 
			
		||||
    if not text_after_filter.strip():
 | 
			
		||||
        text_after_filter = 'Empty content'
 | 
			
		||||
 | 
			
		||||
    # because run_changedetection always returns bytes due to saving the snapshots etc
 | 
			
		||||
    text_after_filter = text_after_filter.decode('utf-8') if isinstance(text_after_filter, bytes) else text_after_filter
 | 
			
		||||
 | 
			
		||||
    return text_after_filter
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def prepare_filter_prevew(datastore, watch_uuid):
 | 
			
		||||
    '''Used by @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])'''
 | 
			
		||||
    from changedetectionio import forms, html_tools
 | 
			
		||||
    from changedetectionio.model.Watch import model as watch_model
 | 
			
		||||
    from concurrent.futures import ProcessPoolExecutor
 | 
			
		||||
    from copy import deepcopy
 | 
			
		||||
    from flask import request, jsonify
 | 
			
		||||
    import brotli
 | 
			
		||||
    import importlib
 | 
			
		||||
    import os
 | 
			
		||||
    import time
 | 
			
		||||
    now = time.time()
 | 
			
		||||
 | 
			
		||||
    text_after_filter = ''
 | 
			
		||||
    text_before_filter = ''
 | 
			
		||||
    trigger_line_numbers = []
 | 
			
		||||
    ignore_line_numbers = []
 | 
			
		||||
 | 
			
		||||
    tmp_watch = deepcopy(datastore.data['watching'].get(watch_uuid))
 | 
			
		||||
 | 
			
		||||
    if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir):
 | 
			
		||||
        # Splice in the temporary stuff from the form
 | 
			
		||||
        form = forms.processor_text_json_diff_form(formdata=request.form if request.method == 'POST' else None,
 | 
			
		||||
                                                   data=request.form
 | 
			
		||||
                                                   )
 | 
			
		||||
 | 
			
		||||
        # Only update vars that came in via the AJAX post
 | 
			
		||||
        p = {k: v for k, v in form.data.items() if k in request.form.keys()}
 | 
			
		||||
        tmp_watch.update(p)
 | 
			
		||||
        blank_watch_no_filters = watch_model()
 | 
			
		||||
        blank_watch_no_filters['url'] = tmp_watch.get('url')
 | 
			
		||||
 | 
			
		||||
        latest_filename = next(reversed(tmp_watch.history))
 | 
			
		||||
        html_fname = os.path.join(tmp_watch.watch_data_dir, f"{latest_filename}.html.br")
 | 
			
		||||
        with open(html_fname, 'rb') as f:
 | 
			
		||||
            decompressed_data = brotli.decompress(f.read()).decode('utf-8') if html_fname.endswith('.br') else f.read().decode('utf-8')
 | 
			
		||||
 | 
			
		||||
            # Just like a normal change detection except provide a fake "watch" object and dont call .call_browser()
 | 
			
		||||
            processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
 | 
			
		||||
            update_handler = processor_module.perform_site_check(datastore=datastore,
 | 
			
		||||
                                                                 watch_uuid=tmp_watch.get('uuid')  # probably not needed anymore anyway?
 | 
			
		||||
                                                                 )
 | 
			
		||||
            # Use the last loaded HTML as the input
 | 
			
		||||
            update_handler.datastore = datastore
 | 
			
		||||
            update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string
 | 
			
		||||
            update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
 | 
			
		||||
 | 
			
		||||
            # Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk
 | 
			
		||||
            # Do this as a parallel process because it could take some time
 | 
			
		||||
            with ProcessPoolExecutor(max_workers=2) as executor:
 | 
			
		||||
                future1 = executor.submit(_task, tmp_watch, update_handler)
 | 
			
		||||
                future2 = executor.submit(_task, blank_watch_no_filters, update_handler)
 | 
			
		||||
 | 
			
		||||
                text_after_filter = future1.result()
 | 
			
		||||
                text_before_filter = future2.result()
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        trigger_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
 | 
			
		||||
                                                            wordlist=tmp_watch['trigger_text'],
 | 
			
		||||
                                                            mode='line numbers'
 | 
			
		||||
                                                            )
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        text_before_filter = f"Error: {str(e)}"
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', [])
 | 
			
		||||
        ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
 | 
			
		||||
                                                           wordlist=text_to_ignore,
 | 
			
		||||
                                                           mode='line numbers'
 | 
			
		||||
                                                           )
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        text_before_filter = f"Error: {str(e)}"
 | 
			
		||||
 | 
			
		||||
    logger.trace(f"Parsed in {time.time() - now:.3f}s")
 | 
			
		||||
 | 
			
		||||
    return jsonify(
 | 
			
		||||
        {
 | 
			
		||||
            'after_filter': text_after_filter,
 | 
			
		||||
            'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
 | 
			
		||||
            'duration': time.time() - now,
 | 
			
		||||
            'trigger_line_numbers': trigger_line_numbers,
 | 
			
		||||
            'ignore_line_numbers': ignore_line_numbers,
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ import re
 | 
			
		||||
import urllib3
 | 
			
		||||
 | 
			
		||||
from changedetectionio.processors import difference_detection_processor
 | 
			
		||||
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text
 | 
			
		||||
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
 | 
			
		||||
from changedetectionio import html_tools, content_fetchers
 | 
			
		||||
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
 | 
			
		||||
from loguru import logger
 | 
			
		||||
@@ -35,7 +35,7 @@ class PDFToHTMLToolNotFound(ValueError):
 | 
			
		||||
# (set_proxy_from_list)
 | 
			
		||||
class perform_site_check(difference_detection_processor):
 | 
			
		||||
 | 
			
		||||
    def run_changedetection(self, watch, skip_when_checksum_same=True):
 | 
			
		||||
    def run_changedetection(self, watch):
 | 
			
		||||
        changed_detected = False
 | 
			
		||||
        html_content = ""
 | 
			
		||||
        screenshot = False  # as bytes
 | 
			
		||||
@@ -58,9 +58,6 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
        # Watches added automatically in the queue manager will skip if its the same checksum as the previous run
 | 
			
		||||
        # Saves a lot of CPU
 | 
			
		||||
        update_obj['previous_md5_before_filters'] = hashlib.md5(self.fetcher.content.encode('utf-8')).hexdigest()
 | 
			
		||||
        if skip_when_checksum_same:
 | 
			
		||||
            if update_obj['previous_md5_before_filters'] == watch.get('previous_md5_before_filters'):
 | 
			
		||||
                raise content_fetchers.exceptions.checksumFromPreviousCheckWasTheSame()
 | 
			
		||||
 | 
			
		||||
        # Fetching complete, now filters
 | 
			
		||||
 | 
			
		||||
@@ -175,13 +172,13 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
                                                                    html_content=self.fetcher.content,
 | 
			
		||||
                                                                    append_pretty_line_formatting=not watch.is_source_type_url,
 | 
			
		||||
                                                                    is_rss=is_rss)
 | 
			
		||||
 | 
			
		||||
                        elif filter_rule.startswith('xpath1:'):
 | 
			
		||||
                            html_content += html_tools.xpath1_filter(xpath_filter=filter_rule.replace('xpath1:', ''),
 | 
			
		||||
                                                                    html_content=self.fetcher.content,
 | 
			
		||||
                                                                    append_pretty_line_formatting=not watch.is_source_type_url,
 | 
			
		||||
                                                                    is_rss=is_rss)
 | 
			
		||||
                                                                     html_content=self.fetcher.content,
 | 
			
		||||
                                                                     append_pretty_line_formatting=not watch.is_source_type_url,
 | 
			
		||||
                                                                     is_rss=is_rss)
 | 
			
		||||
                        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,
 | 
			
		||||
                                                                       html_content=self.fetcher.content,
 | 
			
		||||
                                                                       append_pretty_line_formatting=not watch.is_source_type_url)
 | 
			
		||||
@@ -197,25 +194,21 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
                else:
 | 
			
		||||
                    # extract text
 | 
			
		||||
                    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
 | 
			
		||||
                        )
 | 
			
		||||
                    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
 | 
			
		||||
 | 
			
		||||
        if watch.get('sort_text_alphabetically') and stripped_text_from_html:
 | 
			
		||||
            # Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap
 | 
			
		||||
            # we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here.
 | 
			
		||||
            stripped_text_from_html = stripped_text_from_html.replace('\n\n', '\n')
 | 
			
		||||
            stripped_text_from_html = '\n'.join( sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower() ))
 | 
			
		||||
        if watch.get('trim_text_whitespace'):
 | 
			
		||||
            stripped_text_from_html = '\n'.join(line.strip() for line in stripped_text_from_html.replace("\n\n", "\n").splitlines())
 | 
			
		||||
 | 
			
		||||
        # Re #340 - return the content before the 'ignore text' was applied
 | 
			
		||||
        text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8')
 | 
			
		||||
        # Also used to calculate/show what was removed
 | 
			
		||||
        text_content_before_ignored_filter = stripped_text_from_html
 | 
			
		||||
 | 
			
		||||
        # @todo whitespace coming from missing rtrim()?
 | 
			
		||||
        # stripped_text_from_html could be based on their preferences, replace the processed text with only that which they want to know about.
 | 
			
		||||
        # Rewrite's the processing text based on only what diff result they want to see
 | 
			
		||||
 | 
			
		||||
        if watch.has_special_diff_filter_options_set() and len(watch.history.keys()):
 | 
			
		||||
            # Now the content comes from the diff-parser and not the returned HTTP traffic, so could be some differences
 | 
			
		||||
            from changedetectionio import diff
 | 
			
		||||
@@ -230,12 +223,12 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
                                             line_feed_sep="\n",
 | 
			
		||||
                                             include_change_type_prefix=False)
 | 
			
		||||
 | 
			
		||||
            watch.save_last_text_fetched_before_filters(text_content_before_ignored_filter)
 | 
			
		||||
            watch.save_last_text_fetched_before_filters(text_content_before_ignored_filter.encode('utf-8'))
 | 
			
		||||
 | 
			
		||||
            if not rendered_diff and stripped_text_from_html:
 | 
			
		||||
                # We had some content, but no differences were found
 | 
			
		||||
                # Store our new file as the MD5 so it will trigger in the future
 | 
			
		||||
                c = hashlib.md5(text_content_before_ignored_filter.translate(None, b'\r\n\t ')).hexdigest()
 | 
			
		||||
                c = hashlib.md5(stripped_text_from_html.translate(TRANSLATE_WHITESPACE_TABLE).encode('utf-8')).hexdigest()
 | 
			
		||||
                return False, {'previous_md5': c}, stripped_text_from_html.encode('utf-8')
 | 
			
		||||
            else:
 | 
			
		||||
                stripped_text_from_html = rendered_diff
 | 
			
		||||
@@ -256,14 +249,6 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
 | 
			
		||||
        update_obj["last_check_status"] = self.fetcher.get_last_status_code()
 | 
			
		||||
 | 
			
		||||
        # If there's text to skip
 | 
			
		||||
        # @todo we could abstract out the get_text() to handle this cleaner
 | 
			
		||||
        text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', [])
 | 
			
		||||
        if len(text_to_ignore):
 | 
			
		||||
            stripped_text_from_html = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore)
 | 
			
		||||
        else:
 | 
			
		||||
            stripped_text_from_html = stripped_text_from_html.encode('utf8')
 | 
			
		||||
 | 
			
		||||
        # 615 Extract text by regex
 | 
			
		||||
        extract_text = watch.get('extract_text', [])
 | 
			
		||||
        if len(extract_text) > 0:
 | 
			
		||||
@@ -272,37 +257,53 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
                # incase they specified something in '/.../x'
 | 
			
		||||
                if re.search(PERL_STYLE_REGEX, s_re, re.IGNORECASE):
 | 
			
		||||
                    regex = html_tools.perl_style_slash_enclosed_regex_to_options(s_re)
 | 
			
		||||
                    result = re.findall(regex.encode('utf-8'), stripped_text_from_html)
 | 
			
		||||
                    result = re.findall(regex, stripped_text_from_html)
 | 
			
		||||
 | 
			
		||||
                    for l in result:
 | 
			
		||||
                        if type(l) is tuple:
 | 
			
		||||
                            # @todo - some formatter option default (between groups)
 | 
			
		||||
                            regex_matched_output += list(l) + [b'\n']
 | 
			
		||||
                            regex_matched_output += list(l) + ['\n']
 | 
			
		||||
                        else:
 | 
			
		||||
                            # @todo - some formatter option default (between each ungrouped result)
 | 
			
		||||
                            regex_matched_output += [l] + [b'\n']
 | 
			
		||||
                            regex_matched_output += [l] + ['\n']
 | 
			
		||||
                else:
 | 
			
		||||
                    # Doesnt look like regex, just hunt for plaintext and return that which matches
 | 
			
		||||
                    # `stripped_text_from_html` will be bytes, so we must encode s_re also to bytes
 | 
			
		||||
                    r = re.compile(re.escape(s_re.encode('utf-8')), re.IGNORECASE)
 | 
			
		||||
                    r = re.compile(re.escape(s_re), re.IGNORECASE)
 | 
			
		||||
                    res = r.findall(stripped_text_from_html)
 | 
			
		||||
                    if res:
 | 
			
		||||
                        for match in res:
 | 
			
		||||
                            regex_matched_output += [match] + [b'\n']
 | 
			
		||||
                            regex_matched_output += [match] + ['\n']
 | 
			
		||||
 | 
			
		||||
            ##########################################################
 | 
			
		||||
            stripped_text_from_html = ''
 | 
			
		||||
 | 
			
		||||
            # Now we will only show what the regex matched
 | 
			
		||||
            stripped_text_from_html = b''
 | 
			
		||||
            text_content_before_ignored_filter = b''
 | 
			
		||||
            if regex_matched_output:
 | 
			
		||||
                # @todo some formatter for presentation?
 | 
			
		||||
                stripped_text_from_html = b''.join(regex_matched_output)
 | 
			
		||||
                text_content_before_ignored_filter = stripped_text_from_html
 | 
			
		||||
                stripped_text_from_html = ''.join(regex_matched_output)
 | 
			
		||||
 | 
			
		||||
        if watch.get('remove_duplicate_lines'):
 | 
			
		||||
            stripped_text_from_html = '\n'.join(dict.fromkeys(line for line in stripped_text_from_html.replace("\n\n", "\n").splitlines()))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        if watch.get('sort_text_alphabetically'):
 | 
			
		||||
            # Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap
 | 
			
		||||
            # we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here.
 | 
			
		||||
            stripped_text_from_html = stripped_text_from_html.replace("\n\n", "\n")
 | 
			
		||||
            stripped_text_from_html = '\n'.join(sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower()))
 | 
			
		||||
 | 
			
		||||
### CALCULATE MD5
 | 
			
		||||
        # If there's text to ignore
 | 
			
		||||
        text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', [])
 | 
			
		||||
        text_for_checksuming = stripped_text_from_html
 | 
			
		||||
        if text_to_ignore:
 | 
			
		||||
            text_for_checksuming = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore)
 | 
			
		||||
 | 
			
		||||
        # Re #133 - if we should strip whitespaces from triggering the change detected comparison
 | 
			
		||||
        if self.datastore.data['settings']['application'].get('ignore_whitespace', False):
 | 
			
		||||
            fetched_md5 = hashlib.md5(stripped_text_from_html.translate(None, b'\r\n\t ')).hexdigest()
 | 
			
		||||
        if text_for_checksuming and self.datastore.data['settings']['application'].get('ignore_whitespace', False):
 | 
			
		||||
            fetched_md5 = hashlib.md5(text_for_checksuming.translate(TRANSLATE_WHITESPACE_TABLE).encode('utf-8')).hexdigest()
 | 
			
		||||
        else:
 | 
			
		||||
            fetched_md5 = hashlib.md5(stripped_text_from_html).hexdigest()
 | 
			
		||||
            fetched_md5 = hashlib.md5(text_for_checksuming.encode('utf-8')).hexdigest()
 | 
			
		||||
 | 
			
		||||
        ############ Blocking rules, after checksum #################
 | 
			
		||||
        blocked = False
 | 
			
		||||
@@ -342,7 +343,13 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
 | 
			
		||||
        if changed_detected:
 | 
			
		||||
            if watch.get('check_unique_lines', False):
 | 
			
		||||
                has_unique_lines = watch.lines_contain_something_unique_compared_to_history(lines=stripped_text_from_html.splitlines())
 | 
			
		||||
                ignore_whitespace = self.datastore.data['settings']['application'].get('ignore_whitespace')
 | 
			
		||||
 | 
			
		||||
                has_unique_lines = watch.lines_contain_something_unique_compared_to_history(
 | 
			
		||||
                    lines=stripped_text_from_html.splitlines(),
 | 
			
		||||
                    ignore_whitespace=ignore_whitespace
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                # One or more lines? unsure?
 | 
			
		||||
                if not has_unique_lines:
 | 
			
		||||
                    logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} didnt have anything new setting change_detected=False")
 | 
			
		||||
@@ -357,4 +364,5 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
        if not watch.get('previous_md5'):
 | 
			
		||||
            watch['previous_md5'] = fetched_md5
 | 
			
		||||
 | 
			
		||||
        return changed_detected, update_obj, text_content_before_ignored_filter
 | 
			
		||||
        # stripped_text_from_html - Everything after filters and NO 'ignored' content
 | 
			
		||||
        return changed_detected, update_obj, stripped_text_from_html
 | 
			
		||||
 
 | 
			
		||||
@@ -35,4 +35,8 @@ pytest tests/test_access_control.py
 | 
			
		||||
pytest tests/test_notification.py
 | 
			
		||||
pytest tests/test_backend.py
 | 
			
		||||
pytest tests/test_rss.py
 | 
			
		||||
pytest tests/test_unique_lines.py
 | 
			
		||||
pytest tests/test_unique_lines.py
 | 
			
		||||
 | 
			
		||||
# Check file:// will pickup a file when enabled
 | 
			
		||||
echo "Hello world" > /tmp/test-file.txt
 | 
			
		||||
ALLOW_FILE_URI=yes pytest tests/test_security.py
 | 
			
		||||
 
 | 
			
		||||
@@ -16,25 +16,31 @@ echo "---------------------------------- SOCKS5 -------------------"
 | 
			
		||||
docker run --network changedet-network \
 | 
			
		||||
  -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \
 | 
			
		||||
  --rm \
 | 
			
		||||
  -e "FLASK_SERVER_NAME=cdio" \
 | 
			
		||||
  --hostname cdio \
 | 
			
		||||
  -e "SOCKSTEST=proxiesjson" \
 | 
			
		||||
  test-changedetectionio \
 | 
			
		||||
  bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy_sources.py'
 | 
			
		||||
  bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy_sources.py'
 | 
			
		||||
 | 
			
		||||
# SOCKS5 related - by manually entering in UI
 | 
			
		||||
docker run --network changedet-network \
 | 
			
		||||
  --rm \
 | 
			
		||||
  -e "FLASK_SERVER_NAME=cdio" \
 | 
			
		||||
  --hostname cdio \
 | 
			
		||||
  -e "SOCKSTEST=manual" \
 | 
			
		||||
  test-changedetectionio \
 | 
			
		||||
  bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy.py'
 | 
			
		||||
  bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy.py'
 | 
			
		||||
 | 
			
		||||
# SOCKS5 related - test from proxies.json via playwright - NOTE- PLAYWRIGHT DOESNT SUPPORT AUTHENTICATING PROXY
 | 
			
		||||
docker run --network changedet-network \
 | 
			
		||||
  -e "SOCKSTEST=manual-playwright" \
 | 
			
		||||
  --hostname cdio \
 | 
			
		||||
  -e "FLASK_SERVER_NAME=cdio" \
 | 
			
		||||
  -v `pwd`/tests/proxy_socks5/proxies.json-example-noauth:/app/changedetectionio/test-datastore/proxies.json \
 | 
			
		||||
  -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" \
 | 
			
		||||
  --rm \
 | 
			
		||||
  test-changedetectionio \
 | 
			
		||||
  bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy_sources.py'
 | 
			
		||||
  bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004  -s tests/proxy_socks5/test_socks5_proxy_sources.py'
 | 
			
		||||
 | 
			
		||||
echo "socks5 server logs"
 | 
			
		||||
docker logs socks5proxy
 | 
			
		||||
 
 | 
			
		||||
@@ -18,9 +18,11 @@ $(document).ready(function () {
 | 
			
		||||
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $("#notification-token-toggle").click(function (e) {
 | 
			
		||||
    $(".toggle-show").click(function (e) {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        $('#notification-tokens-info').toggle();
 | 
			
		||||
        let target = $(this).data('target');
 | 
			
		||||
        $(target).toggle();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,56 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * debounce
 | 
			
		||||
 * @param {integer} milliseconds This param indicates the number of milliseconds
 | 
			
		||||
 *     to wait after the last call before calling the original function.
 | 
			
		||||
 * @param {object} What "this" refers to in the returned function.
 | 
			
		||||
 * @return {function} This returns a function that when called will wait the
 | 
			
		||||
 *     indicated number of milliseconds after the last call before
 | 
			
		||||
 *     calling the original function.
 | 
			
		||||
 */
 | 
			
		||||
Function.prototype.debounce = function (milliseconds, context) {
 | 
			
		||||
    var baseFunction = this,
 | 
			
		||||
        timer = null,
 | 
			
		||||
        wait = milliseconds;
 | 
			
		||||
 | 
			
		||||
    return function () {
 | 
			
		||||
        var self = context || this,
 | 
			
		||||
            args = arguments;
 | 
			
		||||
 | 
			
		||||
        function complete() {
 | 
			
		||||
            baseFunction.apply(self, args);
 | 
			
		||||
            timer = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (timer) {
 | 
			
		||||
            clearTimeout(timer);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        timer = setTimeout(complete, wait);
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
* throttle
 | 
			
		||||
* @param {integer} milliseconds This param indicates the number of milliseconds
 | 
			
		||||
*     to wait between calls before calling the original function.
 | 
			
		||||
* @param {object} What "this" refers to in the returned function.
 | 
			
		||||
* @return {function} This returns a function that when called will wait the
 | 
			
		||||
*     indicated number of milliseconds between calls before
 | 
			
		||||
*     calling the original function.
 | 
			
		||||
*/
 | 
			
		||||
Function.prototype.throttle = function (milliseconds, context) {
 | 
			
		||||
    var baseFunction = this,
 | 
			
		||||
        lastEventTimestamp = null,
 | 
			
		||||
        limit = milliseconds;
 | 
			
		||||
 | 
			
		||||
    return function () {
 | 
			
		||||
        var self = context || this,
 | 
			
		||||
            args = arguments,
 | 
			
		||||
            now = Date.now();
 | 
			
		||||
 | 
			
		||||
        if (!lastEventTimestamp || now - lastEventTimestamp >= limit) {
 | 
			
		||||
            lastEventTimestamp = now;
 | 
			
		||||
            baseFunction.apply(self, args);
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										162
									
								
								changedetectionio/static/js/plugins.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								changedetectionio/static/js/plugins.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,162 @@
 | 
			
		||||
(function ($) {
 | 
			
		||||
    /**
 | 
			
		||||
     * debounce
 | 
			
		||||
     * @param {integer} milliseconds This param indicates the number of milliseconds
 | 
			
		||||
     *     to wait after the last call before calling the original function.
 | 
			
		||||
     * @param {object} What "this" refers to in the returned function.
 | 
			
		||||
     * @return {function} This returns a function that when called will wait the
 | 
			
		||||
     *     indicated number of milliseconds after the last call before
 | 
			
		||||
     *     calling the original function.
 | 
			
		||||
     */
 | 
			
		||||
    Function.prototype.debounce = function (milliseconds, context) {
 | 
			
		||||
        var baseFunction = this,
 | 
			
		||||
            timer = null,
 | 
			
		||||
            wait = milliseconds;
 | 
			
		||||
 | 
			
		||||
        return function () {
 | 
			
		||||
            var self = context || this,
 | 
			
		||||
                args = arguments;
 | 
			
		||||
 | 
			
		||||
            function complete() {
 | 
			
		||||
                baseFunction.apply(self, args);
 | 
			
		||||
                timer = null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (timer) {
 | 
			
		||||
                clearTimeout(timer);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            timer = setTimeout(complete, wait);
 | 
			
		||||
        };
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * throttle
 | 
			
		||||
     * @param {integer} milliseconds This param indicates the number of milliseconds
 | 
			
		||||
     *     to wait between calls before calling the original function.
 | 
			
		||||
     * @param {object} What "this" refers to in the returned function.
 | 
			
		||||
     * @return {function} This returns a function that when called will wait the
 | 
			
		||||
     *     indicated number of milliseconds between calls before
 | 
			
		||||
     *     calling the original function.
 | 
			
		||||
     */
 | 
			
		||||
    Function.prototype.throttle = function (milliseconds, context) {
 | 
			
		||||
        var baseFunction = this,
 | 
			
		||||
            lastEventTimestamp = null,
 | 
			
		||||
            limit = milliseconds;
 | 
			
		||||
 | 
			
		||||
        return function () {
 | 
			
		||||
            var self = context || this,
 | 
			
		||||
                args = arguments,
 | 
			
		||||
                now = Date.now();
 | 
			
		||||
 | 
			
		||||
            if (!lastEventTimestamp || now - lastEventTimestamp >= limit) {
 | 
			
		||||
                lastEventTimestamp = now;
 | 
			
		||||
                baseFunction.apply(self, args);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    $.fn.highlightLines = function (configurations) {
 | 
			
		||||
        return this.each(function () {
 | 
			
		||||
            const $pre = $(this);
 | 
			
		||||
            const textContent = $pre.text();
 | 
			
		||||
            const lines = textContent.split(/\r?\n/); // Handles both \n and \r\n line endings
 | 
			
		||||
 | 
			
		||||
            // Build a map of line numbers to styles
 | 
			
		||||
            const lineStyles = {};
 | 
			
		||||
 | 
			
		||||
            configurations.forEach(config => {
 | 
			
		||||
                const {color, lines: lineNumbers} = config;
 | 
			
		||||
                lineNumbers.forEach(lineNumber => {
 | 
			
		||||
                    lineStyles[lineNumber] = color;
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Function to escape HTML characters
 | 
			
		||||
            function escapeHtml(text) {
 | 
			
		||||
                return text.replace(/[&<>"'`=\/]/g, function (s) {
 | 
			
		||||
                    return "&#" + s.charCodeAt(0) + ";";
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Process each line
 | 
			
		||||
            const processedLines = lines.map((line, index) => {
 | 
			
		||||
                const lineNumber = index + 1; // Line numbers start at 1
 | 
			
		||||
                const escapedLine = escapeHtml(line);
 | 
			
		||||
                const color = lineStyles[lineNumber];
 | 
			
		||||
 | 
			
		||||
                if (color) {
 | 
			
		||||
                    // Wrap the line in a span with inline style
 | 
			
		||||
                    return `<span style="background-color: ${color}">${escapedLine}</span>`;
 | 
			
		||||
                } else {
 | 
			
		||||
                    return escapedLine;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Join the lines back together
 | 
			
		||||
            const newContent = processedLines.join('\n');
 | 
			
		||||
 | 
			
		||||
            // Set the new content as HTML
 | 
			
		||||
            $pre.html(newContent);
 | 
			
		||||
        });
 | 
			
		||||
    };
 | 
			
		||||
    $.fn.miniTabs = function (tabsConfig, options) {
 | 
			
		||||
        const settings = {
 | 
			
		||||
            tabClass: 'minitab',
 | 
			
		||||
            tabsContainerClass: 'minitabs',
 | 
			
		||||
            activeClass: 'active',
 | 
			
		||||
            ...(options || {})
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return this.each(function () {
 | 
			
		||||
            const $wrapper = $(this);
 | 
			
		||||
            const $contents = $wrapper.find('div[id]').hide();
 | 
			
		||||
            const $tabsContainer = $('<div>', {class: settings.tabsContainerClass}).prependTo($wrapper);
 | 
			
		||||
 | 
			
		||||
            // Generate tabs
 | 
			
		||||
            Object.entries(tabsConfig).forEach(([tabTitle, contentSelector], index) => {
 | 
			
		||||
                const $content = $wrapper.find(contentSelector);
 | 
			
		||||
                if (index === 0) $content.show(); // Show first content by default
 | 
			
		||||
 | 
			
		||||
                $('<a>', {
 | 
			
		||||
                    class: `${settings.tabClass}${index === 0 ? ` ${settings.activeClass}` : ''}`,
 | 
			
		||||
                    text: tabTitle,
 | 
			
		||||
                    'data-target': contentSelector
 | 
			
		||||
                }).appendTo($tabsContainer);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Tab click event
 | 
			
		||||
            $tabsContainer.on('click', `.${settings.tabClass}`, function (e) {
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
                const $tab = $(this);
 | 
			
		||||
                const target = $tab.data('target');
 | 
			
		||||
 | 
			
		||||
                // Update active tab
 | 
			
		||||
                $tabsContainer.find(`.${settings.tabClass}`).removeClass(settings.activeClass);
 | 
			
		||||
                $tab.addClass(settings.activeClass);
 | 
			
		||||
 | 
			
		||||
                // Show/hide content
 | 
			
		||||
                $contents.hide();
 | 
			
		||||
                $wrapper.find(target).show();
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Object to store ongoing requests by namespace
 | 
			
		||||
    const requests = {};
 | 
			
		||||
 | 
			
		||||
    $.abortiveSingularAjax = function (options) {
 | 
			
		||||
        const namespace = options.namespace || 'default';
 | 
			
		||||
 | 
			
		||||
        // Abort the current request in this namespace if it's still ongoing
 | 
			
		||||
        if (requests[namespace]) {
 | 
			
		||||
            requests[namespace].abort();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Start a new AJAX request and store its reference in the correct namespace
 | 
			
		||||
        requests[namespace] = $.ajax(options);
 | 
			
		||||
 | 
			
		||||
        // Return the current request in case it's needed
 | 
			
		||||
        return requests[namespace];
 | 
			
		||||
    };
 | 
			
		||||
})(jQuery);
 | 
			
		||||
@@ -1,53 +1,63 @@
 | 
			
		||||
function redirect_to_version(version) {
 | 
			
		||||
    var currentUrl = window.location.href;
 | 
			
		||||
    var baseUrl = currentUrl.split('?')[0]; // Base URL without query parameters
 | 
			
		||||
function redirectToVersion(version) {
 | 
			
		||||
    var currentUrl = window.location.href.split('?')[0]; // Base URL without query parameters
 | 
			
		||||
    var anchor = '';
 | 
			
		||||
 | 
			
		||||
    // Check if there is an anchor
 | 
			
		||||
    if (baseUrl.indexOf('#') !== -1) {
 | 
			
		||||
        anchor = baseUrl.substring(baseUrl.indexOf('#'));
 | 
			
		||||
        baseUrl = baseUrl.substring(0, baseUrl.indexOf('#'));
 | 
			
		||||
    if (currentUrl.indexOf('#') !== -1) {
 | 
			
		||||
        anchor = currentUrl.substring(currentUrl.indexOf('#'));
 | 
			
		||||
        currentUrl = currentUrl.substring(0, currentUrl.indexOf('#'));
 | 
			
		||||
    }
 | 
			
		||||
    window.location.href = baseUrl + '?version=' + version + anchor;
 | 
			
		||||
 | 
			
		||||
    window.location.href = currentUrl + '?version=' + version + anchor;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener('keydown', function (event) {
 | 
			
		||||
    var selectElement = document.getElementById('preview-version');
 | 
			
		||||
    if (selectElement) {
 | 
			
		||||
        var selectedOption = selectElement.querySelector('option:checked');
 | 
			
		||||
        if (selectedOption) {
 | 
			
		||||
            if (event.key === 'ArrowLeft') {
 | 
			
		||||
                if (selectedOption.previousElementSibling) {
 | 
			
		||||
                    redirect_to_version(selectedOption.previousElementSibling.value);
 | 
			
		||||
                }
 | 
			
		||||
            } else if (event.key === 'ArrowRight') {
 | 
			
		||||
                if (selectedOption.nextElementSibling) {
 | 
			
		||||
                    redirect_to_version(selectedOption.nextElementSibling.value);
 | 
			
		||||
                }
 | 
			
		||||
function setupDateWidget() {
 | 
			
		||||
    $(document).on('keydown', function (event) {
 | 
			
		||||
        var $selectElement = $('#preview-version');
 | 
			
		||||
        var $selectedOption = $selectElement.find('option:selected');
 | 
			
		||||
 | 
			
		||||
        if ($selectedOption.length) {
 | 
			
		||||
            if (event.key === 'ArrowLeft' && $selectedOption.prev().length) {
 | 
			
		||||
                redirectToVersion($selectedOption.prev().val());
 | 
			
		||||
            } else if (event.key === 'ArrowRight' && $selectedOption.next().length) {
 | 
			
		||||
                redirectToVersion($selectedOption.next().val());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $('#preview-version').on('change', function () {
 | 
			
		||||
        redirectToVersion($(this).val());
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
document.getElementById('preview-version').addEventListener('change', function () {
 | 
			
		||||
    redirect_to_version(this.value);
 | 
			
		||||
});
 | 
			
		||||
    var $selectedOption = $('#preview-version option:selected');
 | 
			
		||||
 | 
			
		||||
var selectElement = document.getElementById('preview-version');
 | 
			
		||||
if (selectElement) {
 | 
			
		||||
    var selectedOption = selectElement.querySelector('option:checked');
 | 
			
		||||
    if (selectedOption) {
 | 
			
		||||
        if (selectedOption.previousElementSibling) {
 | 
			
		||||
            document.getElementById('btn-previous').href = "?version=" + selectedOption.previousElementSibling.value;
 | 
			
		||||
    if ($selectedOption.length) {
 | 
			
		||||
        var $prevOption = $selectedOption.prev();
 | 
			
		||||
        var $nextOption = $selectedOption.next();
 | 
			
		||||
 | 
			
		||||
        if ($prevOption.length) {
 | 
			
		||||
            $('#btn-previous').attr('href', '?version=' + $prevOption.val());
 | 
			
		||||
        } else {
 | 
			
		||||
            document.getElementById('btn-previous').remove()
 | 
			
		||||
        }
 | 
			
		||||
        if (selectedOption.nextElementSibling) {
 | 
			
		||||
            document.getElementById('btn-next').href = "?version=" + selectedOption.nextElementSibling.value;
 | 
			
		||||
        } else {
 | 
			
		||||
            document.getElementById('btn-next').remove()
 | 
			
		||||
            $('#btn-previous').remove();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($nextOption.length) {
 | 
			
		||||
            $('#btn-next').attr('href', '?version=' + $nextOption.val());
 | 
			
		||||
        } else {
 | 
			
		||||
            $('#btn-next').remove();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
$(document).ready(function () {
 | 
			
		||||
    if ($('#preview-version').length) {
 | 
			
		||||
        setupDateWidget();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $('#diff-col > pre').highlightLines([
 | 
			
		||||
        {
 | 
			
		||||
            'color': '#ee0000',
 | 
			
		||||
            'lines': triggered_line_numbers
 | 
			
		||||
        }
 | 
			
		||||
    ]);
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,14 @@
 | 
			
		||||
$(function () {
 | 
			
		||||
    /* add container before each proxy location to show status */
 | 
			
		||||
 | 
			
		||||
    var option_li = $('.fetch-backend-proxy li').filter(function() {
 | 
			
		||||
        return $("input",this)[0].value.length >0;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //var option_li = $('.fetch-backend-proxy li');
 | 
			
		||||
    var isActive = false;
 | 
			
		||||
    $(option_li).prepend('<div class="proxy-status"></div>');
 | 
			
		||||
    $(option_li).append('<div class="proxy-timing"></div><div class="proxy-check-details"></div>');
 | 
			
		||||
 | 
			
		||||
    function setup_html_widget() {
 | 
			
		||||
        var option_li = $('.fetch-backend-proxy li').filter(function () {
 | 
			
		||||
            return $("input", this)[0].value.length > 0;
 | 
			
		||||
        });
 | 
			
		||||
        $(option_li).prepend('<div class="proxy-status"></div>');
 | 
			
		||||
        $(option_li).append('<div class="proxy-timing"></div><div class="proxy-check-details"></div>');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function set_proxy_check_status(proxy_key, state) {
 | 
			
		||||
        // select input by value name
 | 
			
		||||
@@ -59,8 +59,14 @@ $(function () {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $('#check-all-proxies').click(function (e) {
 | 
			
		||||
 | 
			
		||||
        e.preventDefault()
 | 
			
		||||
        $('body').addClass('proxy-check-active');
 | 
			
		||||
 | 
			
		||||
        if (!$('body').hasClass('proxy-check-active')) {
 | 
			
		||||
            setup_html_widget();
 | 
			
		||||
            $('body').addClass('proxy-check-active');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $('.proxy-check-details').html('');
 | 
			
		||||
        $('.proxy-status').html('<span class="spinner"></span>').fadeIn();
 | 
			
		||||
        $('.proxy-timing').html('');
 | 
			
		||||
 
 | 
			
		||||
@@ -49,4 +49,9 @@ $(document).ready(function () {
 | 
			
		||||
        $("#overlay").toggleClass('visible');
 | 
			
		||||
        heartpath.style.fill = document.getElementById("overlay").classList.contains("visible") ? '#ff0000' : 'var(--color-background)';
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setInterval(function () {
 | 
			
		||||
        $('body').toggleClass('spinner-active', $.active > 0);
 | 
			
		||||
    }, 2000);
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,51 @@ function toggleOpacity(checkboxSelector, fieldSelector, inverted) {
 | 
			
		||||
    checkbox.addEventListener('change', updateOpacity);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function request_textpreview_update() {
 | 
			
		||||
    if (!$('body').hasClass('preview-text-enabled')) {
 | 
			
		||||
        console.error("Preview text was requested but body tag was not setup")
 | 
			
		||||
        return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const data = {};
 | 
			
		||||
    $('textarea:visible, input:visible').each(function () {
 | 
			
		||||
        const $element = $(this); // Cache the jQuery object for the current element
 | 
			
		||||
        const name = $element.attr('name'); // Get the name attribute of the element
 | 
			
		||||
        data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $('body').toggleClass('spinner-active', 1);
 | 
			
		||||
 | 
			
		||||
    $.abortiveSingularAjax({
 | 
			
		||||
        type: "POST",
 | 
			
		||||
        url: preview_text_edit_filters_url,
 | 
			
		||||
        data: data,
 | 
			
		||||
        namespace: 'watchEdit'
 | 
			
		||||
    }).done(function (data) {
 | 
			
		||||
        console.debug(data['duration'])
 | 
			
		||||
        $('#filters-and-triggers #text-preview-before-inner').text(data['before_filter']);
 | 
			
		||||
        $('#filters-and-triggers #text-preview-inner')
 | 
			
		||||
            .text(data['after_filter'])
 | 
			
		||||
            .highlightLines([
 | 
			
		||||
                {
 | 
			
		||||
                    'color': '#ee0000',
 | 
			
		||||
                    'lines': data['trigger_line_numbers']
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    'color': '#757575',
 | 
			
		||||
                    'lines': data['ignore_line_numbers']
 | 
			
		||||
                }
 | 
			
		||||
            ])
 | 
			
		||||
    }).fail(function (error) {
 | 
			
		||||
        if (error.statusText === 'abort') {
 | 
			
		||||
            console.log('Request was aborted due to a new request being fired.');
 | 
			
		||||
        } else {
 | 
			
		||||
            $('#filters-and-triggers #text-preview-inner').text('There was an error communicating with the server.');
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
$(document).ready(function () {
 | 
			
		||||
    $('#notification-setting-reset-to-default').click(function (e) {
 | 
			
		||||
        $('#notification_title').val('');
 | 
			
		||||
@@ -27,5 +72,21 @@ $(document).ready(function () {
 | 
			
		||||
 | 
			
		||||
    toggleOpacity('#time_between_check_use_default', '#time_between_check', false);
 | 
			
		||||
 | 
			
		||||
    const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
 | 
			
		||||
    $("#text-preview-inner").css('max-height', (vh-300)+"px");
 | 
			
		||||
    $("#text-preview-before-inner").css('max-height', (vh-300)+"px");
 | 
			
		||||
 | 
			
		||||
    $("#activate-text-preview").click(function (e) {
 | 
			
		||||
        $('body').toggleClass('preview-text-enabled')
 | 
			
		||||
        request_textpreview_update();
 | 
			
		||||
        const method = $('body').hasClass('preview-text-enabled') ? 'on' : 'off';
 | 
			
		||||
        $('#filters-and-triggers textarea')[method]('blur', request_textpreview_update.throttle(1000));
 | 
			
		||||
        $('#filters-and-triggers input')[method]('change', request_textpreview_update.throttle(1000));
 | 
			
		||||
        $("#filters-and-triggers-tab")[method]('click', request_textpreview_update.throttle(1000));
 | 
			
		||||
    });
 | 
			
		||||
    $('.minitabs-wrapper').miniTabs({
 | 
			
		||||
        "Content after filters": "#text-preview-inner",
 | 
			
		||||
        "Content raw/before filters": "#text-preview-before-inner"
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -40,15 +40,29 @@
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#browser-steps-fieldlist {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#browser-steps .flex-wrapper {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-flow: row;
 | 
			
		||||
  height: 70vh;
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
  #browser-steps-ui {
 | 
			
		||||
    flex-grow: 1;      /* Allow it to grow and fill the available space */
 | 
			
		||||
    flex-shrink: 1;    /* Allow it to shrink if needed */
 | 
			
		||||
    flex-basis: 0;     /* Start with 0 base width so it stretches as much as possible */
 | 
			
		||||
    background-color: #eee;
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #browser-steps-fieldlist {
 | 
			
		||||
    flex-grow: 0;      /* Don't allow it to grow */
 | 
			
		||||
    flex-shrink: 0;    /* Don't allow it to shrink */
 | 
			
		||||
    flex-basis: auto;  /* Base width is determined by the content */
 | 
			
		||||
    max-width: 400px;  /* Set a max width to prevent overflow */
 | 
			
		||||
    padding-left: 1rem;
 | 
			
		||||
    overflow-y: scroll;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*  this is duplicate :( */
 | 
			
		||||
 
 | 
			
		||||
@@ -25,15 +25,19 @@ ul#requests-extra_proxies {
 | 
			
		||||
 | 
			
		||||
body.proxy-check-active {
 | 
			
		||||
  #request {
 | 
			
		||||
    // Padding set by flex layout
 | 
			
		||||
    /*
 | 
			
		||||
    .proxy-status {
 | 
			
		||||
      width: 2em;
 | 
			
		||||
    }
 | 
			
		||||
    */
 | 
			
		||||
 | 
			
		||||
    .proxy-check-details {
 | 
			
		||||
      font-size: 80%;
 | 
			
		||||
      color: #555;
 | 
			
		||||
      display: block;
 | 
			
		||||
      padding-left: 4em;
 | 
			
		||||
      padding-left: 2em;
 | 
			
		||||
      max-width: 500px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .proxy-timing {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										47
									
								
								changedetectionio/static/styles/scss/parts/_minitabs.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								changedetectionio/static/styles/scss/parts/_minitabs.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
.minitabs-wrapper {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
 | 
			
		||||
  > div[id] {
 | 
			
		||||
    padding: 20px;
 | 
			
		||||
    border: 1px solid #ccc;
 | 
			
		||||
    border-top: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .minitabs-content {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    > div {
 | 
			
		||||
      flex: 1 1 auto;
 | 
			
		||||
      min-width: 0;
 | 
			
		||||
      overflow: scroll;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .minitabs {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    border-bottom: 1px solid #ccc;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .minitab {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    padding: 12px 0;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    color: #333;
 | 
			
		||||
    background-color: #f1f1f1;
 | 
			
		||||
    border: 1px solid #ccc;
 | 
			
		||||
    border-bottom: none;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    transition: background-color 0.3s;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .minitab:hover {
 | 
			
		||||
    background-color: #ddd;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .minitab.active {
 | 
			
		||||
    background-color: #fff;
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,55 @@
 | 
			
		||||
@import "minitabs";
 | 
			
		||||
 | 
			
		||||
body.preview-text-enabled {
 | 
			
		||||
 | 
			
		||||
  @media (min-width: 800px) {
 | 
			
		||||
    #filters-and-triggers > div {
 | 
			
		||||
      display: flex; /* Establishes Flexbox layout */
 | 
			
		||||
      gap: 20px; /* Adds space between the columns */
 | 
			
		||||
      position: relative; /* Ensures the sticky positioning is relative to this parent */
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* layout of the page */
 | 
			
		||||
  #edit-text-filter, #text-preview {
 | 
			
		||||
    flex: 1; /* Each column takes an equal amount of available space */
 | 
			
		||||
    align-self: flex-start; /* Aligns the right column to the start, allowing it to maintain its content height */
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #edit-text-filter {
 | 
			
		||||
    #pro-tips {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #text-preview {
 | 
			
		||||
    position: sticky;
 | 
			
		||||
    top: 20px;
 | 
			
		||||
    padding-top: 1rem;
 | 
			
		||||
    padding-bottom: 1rem;
 | 
			
		||||
    display: block !important;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #activate-text-preview {
 | 
			
		||||
      background-color: var(--color-grey-500);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* actual preview area */
 | 
			
		||||
  .monospace-preview {
 | 
			
		||||
    background: var(--color-background-input);
 | 
			
		||||
    border: 1px solid var(--color-grey-600);
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
    color: var(--color-text-input);
 | 
			
		||||
    font-family: "Courier New", Courier, monospace; /* Sets the font to a monospace type */
 | 
			
		||||
    font-size: 70%;
 | 
			
		||||
    word-break: break-word;
 | 
			
		||||
    white-space: pre-wrap; /* Preserves whitespace and line breaks like <pre> */
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#activate-text-preview {
 | 
			
		||||
  right: 0;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  z-index: 3;
 | 
			
		||||
  box-shadow: 1px 1px 4px var(--color-shadow-jump);
 | 
			
		||||
}
 | 
			
		||||
@@ -12,6 +12,7 @@
 | 
			
		||||
@import "parts/_darkmode";
 | 
			
		||||
@import "parts/_menu";
 | 
			
		||||
@import "parts/_love";
 | 
			
		||||
@import "parts/preview_text_filter";
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
@@ -105,10 +106,34 @@ button.toggle-button {
 | 
			
		||||
  padding: 5px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  border-bottom: 2px solid var(--color-menu-accent);
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#pure-menu-horizontal-spinner {
 | 
			
		||||
  height: 3px;
 | 
			
		||||
  background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);
 | 
			
		||||
  background-size: 400% 400%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  animation: gradient 200s ease infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body.spinner-active {
 | 
			
		||||
  #pure-menu-horizontal-spinner {
 | 
			
		||||
    animation: gradient 1s ease infinite;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes gradient {
 | 
			
		||||
	0% {
 | 
			
		||||
		background-position: 0% 50%;
 | 
			
		||||
	}
 | 
			
		||||
	50% {
 | 
			
		||||
		background-position: 100% 50%;
 | 
			
		||||
	}
 | 
			
		||||
	100% {
 | 
			
		||||
		background-position: 0% 50%;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
.pure-menu-heading {
 | 
			
		||||
  color: var(--color-text-menu-heading);
 | 
			
		||||
}
 | 
			
		||||
@@ -122,8 +147,14 @@ button.toggle-button {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.tabs ul li a {
 | 
			
		||||
  // .tab-pane-inner will have the #id that the tab button jumps/anchors to
 | 
			
		||||
  scroll-margin-top: 200px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
section.content {
 | 
			
		||||
  padding-top: 5em;
 | 
			
		||||
  padding-top: 100px;
 | 
			
		||||
  padding-bottom: 1em;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  display: flex;
 | 
			
		||||
@@ -320,10 +351,6 @@ a.pure-button-selected {
 | 
			
		||||
  background: var(--color-background-button-cancel);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#save_button {
 | 
			
		||||
  margin-right: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.messages {
 | 
			
		||||
  li {
 | 
			
		||||
    list-style: none;
 | 
			
		||||
@@ -620,9 +647,9 @@ footer {
 | 
			
		||||
      list-style: none;
 | 
			
		||||
 | 
			
		||||
      li {
 | 
			
		||||
        >* {
 | 
			
		||||
          display: inline-block;
 | 
			
		||||
        }
 | 
			
		||||
        display: flex;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        gap: 1em;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -682,6 +709,12 @@ footer {
 | 
			
		||||
      tr {
 | 
			
		||||
        th {
 | 
			
		||||
          display: inline-block;
 | 
			
		||||
          // Hide the "Last" text for smaller screens
 | 
			
		||||
          @media (max-width: 768px) {
 | 
			
		||||
            .hide-on-mobile {
 | 
			
		||||
              display: none; 
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      .empty-cell {
 | 
			
		||||
@@ -697,6 +730,24 @@ footer {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    tbody {
 | 
			
		||||
      tr {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-wrap: wrap;
 | 
			
		||||
 | 
			
		||||
        // The third child of each row will take up the remaining space
 | 
			
		||||
        // This is useful for the URL column, which should expand to fill the remaining space
 | 
			
		||||
        :nth-child(3) {
 | 
			
		||||
          flex-grow: 1;
 | 
			
		||||
        }
 | 
			
		||||
        // The last three children (from the end) of each row will take up the full width
 | 
			
		||||
        // This is useful for the "Last Checked", "Last Changed", and the action buttons columns, which should each take up the full width
 | 
			
		||||
        :nth-last-child(-n+3) {
 | 
			
		||||
          flex-basis: 100%;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .last-checked {
 | 
			
		||||
      >span {
 | 
			
		||||
        vertical-align: middle;
 | 
			
		||||
@@ -815,6 +866,11 @@ textarea::placeholder {
 | 
			
		||||
- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out
 | 
			
		||||
- Rely always on width in CSS
 | 
			
		||||
*/
 | 
			
		||||
/** Set max width for input field */
 | 
			
		||||
.m-d {
 | 
			
		||||
  min-width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media only screen and (min-width: 761px) {
 | 
			
		||||
 | 
			
		||||
  /* m-d is medium-desktop */
 | 
			
		||||
@@ -881,6 +937,7 @@ $form-edge-padding: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-pane-inner {
 | 
			
		||||
 | 
			
		||||
  &:not(:target) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
@@ -930,6 +987,13 @@ body.full-width {
 | 
			
		||||
    background: var(--color-background);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* Make action buttons have consistent size and spacing */
 | 
			
		||||
  #actions .pure-control-group {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    gap: 0.625em;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .pure-form-message-inline {
 | 
			
		||||
    padding-left: 0;
 | 
			
		||||
    color: var(--color-text-input-description);
 | 
			
		||||
@@ -973,6 +1037,28 @@ ul {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media only screen and (max-width: 760px) {
 | 
			
		||||
  .time-check-widget {
 | 
			
		||||
    tbody {
 | 
			
		||||
      display: grid;
 | 
			
		||||
      grid-template-columns: auto 1fr auto 1fr;
 | 
			
		||||
      gap: 0.625em 0.3125em;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
    }    
 | 
			
		||||
    tr {
 | 
			
		||||
      display: contents; 
 | 
			
		||||
      th {
 | 
			
		||||
        text-align: right;
 | 
			
		||||
        padding-right: 5px;
 | 
			
		||||
      }
 | 
			
		||||
      input[type="number"] {
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        max-width: 5em;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@import "parts/_visualselector";
 | 
			
		||||
 | 
			
		||||
#webdriver_delay {
 | 
			
		||||
 
 | 
			
		||||
@@ -46,14 +46,31 @@
 | 
			
		||||
    #browser_steps li > label {
 | 
			
		||||
      display: none; }
 | 
			
		||||
 | 
			
		||||
#browser-steps-fieldlist {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  overflow-y: scroll; }
 | 
			
		||||
 | 
			
		||||
#browser-steps .flex-wrapper {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-flow: row;
 | 
			
		||||
  height: 70vh; }
 | 
			
		||||
  height: 70vh;
 | 
			
		||||
  font-size: 80%; }
 | 
			
		||||
  #browser-steps .flex-wrapper #browser-steps-ui {
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
    /* Allow it to grow and fill the available space */
 | 
			
		||||
    flex-shrink: 1;
 | 
			
		||||
    /* Allow it to shrink if needed */
 | 
			
		||||
    flex-basis: 0;
 | 
			
		||||
    /* Start with 0 base width so it stretches as much as possible */
 | 
			
		||||
    background-color: #eee;
 | 
			
		||||
    border-radius: 5px; }
 | 
			
		||||
  #browser-steps .flex-wrapper #browser-steps-fieldlist {
 | 
			
		||||
    flex-grow: 0;
 | 
			
		||||
    /* Don't allow it to grow */
 | 
			
		||||
    flex-shrink: 0;
 | 
			
		||||
    /* Don't allow it to shrink */
 | 
			
		||||
    flex-basis: auto;
 | 
			
		||||
    /* Base width is determined by the content */
 | 
			
		||||
    max-width: 400px;
 | 
			
		||||
    /* Set a max width to prevent overflow */
 | 
			
		||||
    padding-left: 1rem;
 | 
			
		||||
    overflow-y: scroll; }
 | 
			
		||||
 | 
			
		||||
/*  this is duplicate :( */
 | 
			
		||||
#browsersteps-selector-wrapper {
 | 
			
		||||
@@ -102,19 +119,22 @@ ul#requests-extra_proxies {
 | 
			
		||||
  #request label[for=proxy] {
 | 
			
		||||
    display: inline-block; }
 | 
			
		||||
 | 
			
		||||
body.proxy-check-active #request .proxy-status {
 | 
			
		||||
  width: 2em; }
 | 
			
		||||
 | 
			
		||||
body.proxy-check-active #request .proxy-check-details {
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
  color: #555;
 | 
			
		||||
  display: block;
 | 
			
		||||
  padding-left: 4em; }
 | 
			
		||||
 | 
			
		||||
body.proxy-check-active #request .proxy-timing {
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
  padding-left: 1rem;
 | 
			
		||||
  color: var(--color-link); }
 | 
			
		||||
body.proxy-check-active #request {
 | 
			
		||||
  /*
 | 
			
		||||
    .proxy-status {
 | 
			
		||||
      width: 2em;
 | 
			
		||||
    }
 | 
			
		||||
    */ }
 | 
			
		||||
  body.proxy-check-active #request .proxy-check-details {
 | 
			
		||||
    font-size: 80%;
 | 
			
		||||
    color: #555;
 | 
			
		||||
    display: block;
 | 
			
		||||
    padding-left: 2em;
 | 
			
		||||
    max-width: 500px; }
 | 
			
		||||
  body.proxy-check-active #request .proxy-timing {
 | 
			
		||||
    font-size: 80%;
 | 
			
		||||
    padding-left: 1rem;
 | 
			
		||||
    color: var(--color-link); }
 | 
			
		||||
 | 
			
		||||
#recommended-proxy {
 | 
			
		||||
  display: grid;
 | 
			
		||||
@@ -411,6 +431,83 @@ html[data-darkmode="true"] #toggle-light-mode .icon-dark {
 | 
			
		||||
    fill: #ff0000 !important;
 | 
			
		||||
    transition: all ease 0.3s !important; }
 | 
			
		||||
 | 
			
		||||
.minitabs-wrapper {
 | 
			
		||||
  width: 100%; }
 | 
			
		||||
  .minitabs-wrapper > div[id] {
 | 
			
		||||
    padding: 20px;
 | 
			
		||||
    border: 1px solid #ccc;
 | 
			
		||||
    border-top: none; }
 | 
			
		||||
  .minitabs-wrapper .minitabs-content {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    display: flex; }
 | 
			
		||||
    .minitabs-wrapper .minitabs-content > div {
 | 
			
		||||
      flex: 1 1 auto;
 | 
			
		||||
      min-width: 0;
 | 
			
		||||
      overflow: scroll; }
 | 
			
		||||
  .minitabs-wrapper .minitabs {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    border-bottom: 1px solid #ccc; }
 | 
			
		||||
  .minitabs-wrapper .minitab {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    padding: 12px 0;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    color: #333;
 | 
			
		||||
    background-color: #f1f1f1;
 | 
			
		||||
    border: 1px solid #ccc;
 | 
			
		||||
    border-bottom: none;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    transition: background-color 0.3s; }
 | 
			
		||||
  .minitabs-wrapper .minitab:hover {
 | 
			
		||||
    background-color: #ddd; }
 | 
			
		||||
  .minitabs-wrapper .minitab.active {
 | 
			
		||||
    background-color: #fff;
 | 
			
		||||
    font-weight: bold; }
 | 
			
		||||
 | 
			
		||||
body.preview-text-enabled {
 | 
			
		||||
  /* layout of the page */
 | 
			
		||||
  /* actual preview area */ }
 | 
			
		||||
  @media (min-width: 800px) {
 | 
			
		||||
    body.preview-text-enabled #filters-and-triggers > div {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      /* Establishes Flexbox layout */
 | 
			
		||||
      gap: 20px;
 | 
			
		||||
      /* Adds space between the columns */
 | 
			
		||||
      position: relative;
 | 
			
		||||
      /* Ensures the sticky positioning is relative to this parent */ } }
 | 
			
		||||
  body.preview-text-enabled #edit-text-filter, body.preview-text-enabled #text-preview {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    /* Each column takes an equal amount of available space */
 | 
			
		||||
    align-self: flex-start;
 | 
			
		||||
    /* Aligns the right column to the start, allowing it to maintain its content height */ }
 | 
			
		||||
  body.preview-text-enabled #edit-text-filter #pro-tips {
 | 
			
		||||
    display: none; }
 | 
			
		||||
  body.preview-text-enabled #text-preview {
 | 
			
		||||
    position: sticky;
 | 
			
		||||
    top: 20px;
 | 
			
		||||
    padding-top: 1rem;
 | 
			
		||||
    padding-bottom: 1rem;
 | 
			
		||||
    display: block !important; }
 | 
			
		||||
  body.preview-text-enabled #activate-text-preview {
 | 
			
		||||
    background-color: var(--color-grey-500); }
 | 
			
		||||
  body.preview-text-enabled .monospace-preview {
 | 
			
		||||
    background: var(--color-background-input);
 | 
			
		||||
    border: 1px solid var(--color-grey-600);
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
    color: var(--color-text-input);
 | 
			
		||||
    font-family: "Courier New", Courier, monospace;
 | 
			
		||||
    /* Sets the font to a monospace type */
 | 
			
		||||
    font-size: 70%;
 | 
			
		||||
    word-break: break-word;
 | 
			
		||||
    white-space: pre-wrap;
 | 
			
		||||
    /* Preserves whitespace and line breaks like <pre> */ }
 | 
			
		||||
 | 
			
		||||
#activate-text-preview {
 | 
			
		||||
  right: 0;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  z-index: 3;
 | 
			
		||||
  box-shadow: 1px 1px 4px var(--color-shadow-jump); }
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
  background: var(--color-background-page);
 | 
			
		||||
@@ -479,9 +576,26 @@ button.toggle-button {
 | 
			
		||||
  padding: 5px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  border-bottom: 2px solid var(--color-menu-accent);
 | 
			
		||||
  align-items: center; }
 | 
			
		||||
 | 
			
		||||
#pure-menu-horizontal-spinner {
 | 
			
		||||
  height: 3px;
 | 
			
		||||
  background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);
 | 
			
		||||
  background-size: 400% 400%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  animation: gradient 200s ease infinite; }
 | 
			
		||||
 | 
			
		||||
body.spinner-active #pure-menu-horizontal-spinner {
 | 
			
		||||
  animation: gradient 1s ease infinite; }
 | 
			
		||||
 | 
			
		||||
@keyframes gradient {
 | 
			
		||||
  0% {
 | 
			
		||||
    background-position: 0% 50%; }
 | 
			
		||||
  50% {
 | 
			
		||||
    background-position: 100% 50%; }
 | 
			
		||||
  100% {
 | 
			
		||||
    background-position: 0% 50%; } }
 | 
			
		||||
 | 
			
		||||
.pure-menu-heading {
 | 
			
		||||
  color: var(--color-text-menu-heading); }
 | 
			
		||||
 | 
			
		||||
@@ -491,8 +605,12 @@ button.toggle-button {
 | 
			
		||||
    background-color: var(--color-background-menu-link-hover);
 | 
			
		||||
    color: var(--color-text-menu-link-hover); }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.tabs ul li a {
 | 
			
		||||
  scroll-margin-top: 200px; }
 | 
			
		||||
 | 
			
		||||
section.content {
 | 
			
		||||
  padding-top: 5em;
 | 
			
		||||
  padding-top: 100px;
 | 
			
		||||
  padding-bottom: 1em;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  display: flex;
 | 
			
		||||
@@ -634,9 +752,6 @@ a.pure-button-selected {
 | 
			
		||||
.button-cancel {
 | 
			
		||||
  background: var(--color-background-button-cancel); }
 | 
			
		||||
 | 
			
		||||
#save_button {
 | 
			
		||||
  margin-right: 1rem; }
 | 
			
		||||
 | 
			
		||||
.messages li {
 | 
			
		||||
  list-style: none;
 | 
			
		||||
  padding: 1em;
 | 
			
		||||
@@ -835,8 +950,10 @@ footer {
 | 
			
		||||
  .pure-form .inline-radio ul {
 | 
			
		||||
    margin: 0px;
 | 
			
		||||
    list-style: none; }
 | 
			
		||||
    .pure-form .inline-radio ul li > * {
 | 
			
		||||
      display: inline-block; }
 | 
			
		||||
    .pure-form .inline-radio ul li {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      gap: 1em; }
 | 
			
		||||
 | 
			
		||||
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
 | 
			
		||||
  .box {
 | 
			
		||||
@@ -872,12 +989,24 @@ footer {
 | 
			
		||||
    .watch-table thead {
 | 
			
		||||
      display: block; }
 | 
			
		||||
      .watch-table thead tr th {
 | 
			
		||||
        display: inline-block; }
 | 
			
		||||
        display: inline-block; } }
 | 
			
		||||
      @media only screen and (max-width: 760px) and (max-width: 768px), (min-device-width: 768px) and (max-device-width: 800px) and (max-width: 768px) {
 | 
			
		||||
        .watch-table thead tr th .hide-on-mobile {
 | 
			
		||||
          display: none; } }
 | 
			
		||||
 | 
			
		||||
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 800px) {
 | 
			
		||||
      .watch-table thead .empty-cell {
 | 
			
		||||
        display: none; }
 | 
			
		||||
    .watch-table tbody td,
 | 
			
		||||
    .watch-table tbody tr {
 | 
			
		||||
      display: block; }
 | 
			
		||||
    .watch-table tbody tr {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-wrap: wrap; }
 | 
			
		||||
      .watch-table tbody tr :nth-child(3) {
 | 
			
		||||
        flex-grow: 1; }
 | 
			
		||||
      .watch-table tbody tr :nth-last-child(-n+3) {
 | 
			
		||||
        flex-basis: 100%; }
 | 
			
		||||
    .watch-table .last-checked > span {
 | 
			
		||||
      vertical-align: middle; }
 | 
			
		||||
    .watch-table .last-checked::before {
 | 
			
		||||
@@ -969,6 +1098,10 @@ textarea::placeholder {
 | 
			
		||||
- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out
 | 
			
		||||
- Rely always on width in CSS
 | 
			
		||||
*/
 | 
			
		||||
/** Set max width for input field */
 | 
			
		||||
.m-d {
 | 
			
		||||
  min-width: 100%; }
 | 
			
		||||
 | 
			
		||||
@media only screen and (min-width: 761px) {
 | 
			
		||||
  /* m-d is medium-desktop */
 | 
			
		||||
  .m-d {
 | 
			
		||||
@@ -1029,7 +1162,8 @@ body.full-width .edit-form {
 | 
			
		||||
.edit-form {
 | 
			
		||||
  min-width: 70%;
 | 
			
		||||
  /* so it cant overflow */
 | 
			
		||||
  max-width: 95%; }
 | 
			
		||||
  max-width: 95%;
 | 
			
		||||
  /* Make action buttons have consistent size and spacing */ }
 | 
			
		||||
  .edit-form .box-wrap {
 | 
			
		||||
    position: relative; }
 | 
			
		||||
  .edit-form .inner {
 | 
			
		||||
@@ -1038,6 +1172,10 @@ body.full-width .edit-form {
 | 
			
		||||
  .edit-form #actions {
 | 
			
		||||
    display: block;
 | 
			
		||||
    background: var(--color-background); }
 | 
			
		||||
  .edit-form #actions .pure-control-group {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    gap: 0.625em;
 | 
			
		||||
    flex-wrap: wrap; }
 | 
			
		||||
  .edit-form .pure-form-message-inline {
 | 
			
		||||
    padding-left: 0;
 | 
			
		||||
    color: var(--color-text-input-description); }
 | 
			
		||||
@@ -1066,6 +1204,21 @@ ul {
 | 
			
		||||
  .time-check-widget tr input[type="number"] {
 | 
			
		||||
    width: 5em; }
 | 
			
		||||
 | 
			
		||||
@media only screen and (max-width: 760px) {
 | 
			
		||||
  .time-check-widget tbody {
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-columns: auto 1fr auto 1fr;
 | 
			
		||||
    gap: 0.625em 0.3125em;
 | 
			
		||||
    align-items: center; }
 | 
			
		||||
  .time-check-widget tr {
 | 
			
		||||
    display: contents; }
 | 
			
		||||
    .time-check-widget tr th {
 | 
			
		||||
      text-align: right;
 | 
			
		||||
      padding-right: 5px; }
 | 
			
		||||
    .time-check-widget tr input[type="number"] {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      max-width: 5em; } }
 | 
			
		||||
 | 
			
		||||
#selector-wrapper {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
@@ -1194,11 +1347,9 @@ ul {
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  opacity: 0.7; }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.restock-label svg {
 | 
			
		||||
  vertical-align: middle; }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#chrome-extension-link {
 | 
			
		||||
  padding: 9px;
 | 
			
		||||
  border: 1px solid var(--color-grey-800);
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ from flask import (
 | 
			
		||||
    flash
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
from .html_tools import TRANSLATE_WHITESPACE_TABLE
 | 
			
		||||
from . model import App, Watch
 | 
			
		||||
from copy import deepcopy, copy
 | 
			
		||||
from os import path, unlink
 | 
			
		||||
@@ -11,7 +12,6 @@ from threading import Lock
 | 
			
		||||
import json
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import requests
 | 
			
		||||
import secrets
 | 
			
		||||
import threading
 | 
			
		||||
import time
 | 
			
		||||
@@ -270,6 +270,7 @@ class ChangeDetectionStore:
 | 
			
		||||
        self.needs_write_urgent = True
 | 
			
		||||
 | 
			
		||||
    def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True):
 | 
			
		||||
        import requests
 | 
			
		||||
 | 
			
		||||
        if extras is None:
 | 
			
		||||
            extras = {}
 | 
			
		||||
@@ -750,17 +751,17 @@ class ChangeDetectionStore:
 | 
			
		||||
    def update_5(self):
 | 
			
		||||
        # If the watch notification body, title look the same as the global one, unset it, so the watch defaults back to using the main settings
 | 
			
		||||
        # In other words - the watch notification_title and notification_body are not needed if they are the same as the default one
 | 
			
		||||
        current_system_body = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n "))
 | 
			
		||||
        current_system_title = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n "))
 | 
			
		||||
        current_system_body = self.data['settings']['application']['notification_body'].translate(TRANSLATE_WHITESPACE_TABLE)
 | 
			
		||||
        current_system_title = self.data['settings']['application']['notification_body'].translate(TRANSLATE_WHITESPACE_TABLE)
 | 
			
		||||
        for uuid, watch in self.data['watching'].items():
 | 
			
		||||
            try:
 | 
			
		||||
                watch_body = watch.get('notification_body', '')
 | 
			
		||||
                if watch_body and watch_body.translate(str.maketrans('', '', "\r\n ")) == current_system_body:
 | 
			
		||||
                if watch_body and watch_body.translate(TRANSLATE_WHITESPACE_TABLE) == current_system_body:
 | 
			
		||||
                    # Looks the same as the default one, so unset it
 | 
			
		||||
                    watch['notification_body'] = None
 | 
			
		||||
 | 
			
		||||
                watch_title = watch.get('notification_title', '')
 | 
			
		||||
                if watch_title and watch_title.translate(str.maketrans('', '', "\r\n ")) == current_system_title:
 | 
			
		||||
                if watch_title and watch_title.translate(TRANSLATE_WHITESPACE_TABLE) == current_system_title:
 | 
			
		||||
                    # Looks the same as the default one, so unset it
 | 
			
		||||
                    watch['notification_title'] = None
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
 
 | 
			
		||||
@@ -11,8 +11,11 @@
 | 
			
		||||
    class="notification-urls" )
 | 
			
		||||
                            }}
 | 
			
		||||
                            <div class="pure-form-message-inline">
 | 
			
		||||
                              <ul>
 | 
			
		||||
                                <li>Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.</li>
 | 
			
		||||
                                <p>
 | 
			
		||||
                                <strong>Tip:</strong> Use <a target=_new href="https://github.com/caronc/apprise">AppRise Notification URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.<br>
 | 
			
		||||
</p>
 | 
			
		||||
                                <div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div>
 | 
			
		||||
                                <ul style="display: none" id="advanced-help-notifications">
 | 
			
		||||
                                <li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li>
 | 
			
		||||
                                <li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't send messages to other bots, so you should specify chat ID of non-bot user.</li>
 | 
			
		||||
                                <li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li>
 | 
			
		||||
@@ -40,7 +43,7 @@
 | 
			
		||||
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="pure-controls">
 | 
			
		||||
                                <div id="notification-token-toggle" class="pure-button button-tag button-xsmall">Show token/placeholders</div>
 | 
			
		||||
                                <div data-target="#notification-tokens-info" class="toggle-show pure-button button-tag button-xsmall">Show token/placeholders</div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="pure-controls" style="display: none;" id="notification-tokens-info">
 | 
			
		||||
                                <table class="pure-table" id="token-table">
 | 
			
		||||
 
 | 
			
		||||
@@ -33,9 +33,11 @@
 | 
			
		||||
    <script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
 | 
			
		||||
  </head>
 | 
			
		||||
 | 
			
		||||
  <body>
 | 
			
		||||
  <body class="">
 | 
			
		||||
    <div class="header">
 | 
			
		||||
      <div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed" id="nav-menu">
 | 
			
		||||
    <div class="pure-menu-fixed" style="width: 100%;">
 | 
			
		||||
      <div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
 | 
			
		||||
 | 
			
		||||
        {% if has_password and not current_user.is_authenticated %}
 | 
			
		||||
          <a class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
 | 
			
		||||
            <strong>Change</strong>Detection.io</a>
 | 
			
		||||
@@ -129,7 +131,12 @@
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="pure-menu-horizontal-spinner"></div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    {% if hosted_sticky %}
 | 
			
		||||
      <div class="sticky-tab" id="hosted-sticky">
 | 
			
		||||
        <a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@
 | 
			
		||||
{% from '_common_fields.html' import render_common_settings_form %}
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='global-settings.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 }}');
 | 
			
		||||
@@ -23,9 +24,8 @@
 | 
			
		||||
    const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}";
 | 
			
		||||
    const default_system_fetch_backend="{{ settings_application['fetch_backend'] }}";
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script>
 | 
			
		||||
{% if playwright_enabled %}
 | 
			
		||||
@@ -49,7 +49,7 @@
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if watch['processor'] == 'text_json_diff' %}
 | 
			
		||||
            <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
 | 
			
		||||
            <li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
 | 
			
		||||
            <li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            <li class="tab"><a href="#notifications">Notifications</a></li>
 | 
			
		||||
            <li class="tab"><a href="#stats">Stats</a></li>
 | 
			
		||||
@@ -199,7 +199,7 @@ User-Agent: wonderbra 1.0") }}
 | 
			
		||||
                        <div id="loading-status-text" style="display: none;">Please wait, first browser step can take a little time to load..<div class="spinner"></div></div>
 | 
			
		||||
                        <div class="flex-wrapper" >
 | 
			
		||||
 | 
			
		||||
                            <div id="browser-steps-ui" class="noselect"  style="width: 100%; background-color: #eee; border-radius: 5px;">
 | 
			
		||||
                            <div id="browser-steps-ui" class="noselect">
 | 
			
		||||
 | 
			
		||||
                                <div class="noselect"  id="browsersteps-selector-wrapper" style="width: 100%">
 | 
			
		||||
                                    <span class="loader" >
 | 
			
		||||
@@ -214,7 +214,7 @@ User-Agent: wonderbra 1.0") }}
 | 
			
		||||
                                    <canvas  class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div id="browser-steps-fieldlist" style="padding-left: 1em;  width: 350px; font-size: 80%;" >
 | 
			
		||||
                            <div id="browser-steps-fieldlist" >
 | 
			
		||||
                                <span id="browser-seconds-remaining">Loading</span> <span style="font-size: 80%;"> (<a target=_new href="https://github.com/dgtlmoon/changedetection.io/pull/478/files#diff-1a79d924d1840c485238e66772391268a89c95b781d69091384cf1ea1ac146c9R4">?</a>) </span>
 | 
			
		||||
                                {{ render_field(form.browser_steps) }}
 | 
			
		||||
                            </div>
 | 
			
		||||
@@ -253,7 +253,10 @@ User-Agent: wonderbra 1.0") }}
 | 
			
		||||
 | 
			
		||||
            {% if watch['processor'] == 'text_json_diff' %}
 | 
			
		||||
            <div class="tab-pane-inner" id="filters-and-triggers">
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
                <span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span>
 | 
			
		||||
              <div>
 | 
			
		||||
              <div id="edit-text-filter">
 | 
			
		||||
                    <div class="pure-control-group" id="pro-tips">
 | 
			
		||||
                            <strong>Pro-tips:</strong><br>
 | 
			
		||||
                            <ul>
 | 
			
		||||
                                <li>
 | 
			
		||||
@@ -275,9 +278,9 @@ xpath://body/div/span[contains(@class, 'example-class')]",
 | 
			
		||||
                        {% if '/text()' in  field %}
 | 
			
		||||
                          <span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                        <span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br>
 | 
			
		||||
 | 
			
		||||
                    <ul>
 | 
			
		||||
                        <span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
 | 
			
		||||
<p><div data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div><br></p>
 | 
			
		||||
                    <ul id="advanced-help-selectors" style="display: none;">
 | 
			
		||||
                        <li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
 | 
			
		||||
                        <li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed).
 | 
			
		||||
                            <ul>
 | 
			
		||||
@@ -297,21 +300,25 @@ xpath://body/div/span[contains(@class, 'example-class')]",
 | 
			
		||||
                                <li>To use XPath1.0: Prefix with <code>xpath1:</code></li>
 | 
			
		||||
                            </ul>
 | 
			
		||||
                            </li>
 | 
			
		||||
                    </ul>
 | 
			
		||||
                    Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a
 | 
			
		||||
                    <li>
 | 
			
		||||
                        Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a
 | 
			
		||||
                                href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    </ul>
 | 
			
		||||
 | 
			
		||||
                </span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                <fieldset class="pure-control-group">
 | 
			
		||||
                    {{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header
 | 
			
		||||
footer
 | 
			
		||||
nav
 | 
			
		||||
.stockticker") }}
 | 
			
		||||
.stockticker
 | 
			
		||||
//*[contains(text(), 'Advertisement')]") }}
 | 
			
		||||
                    <span class="pure-form-message-inline">
 | 
			
		||||
                        <ul>
 | 
			
		||||
                          <li> Remove HTML element(s) by CSS selector before text conversion. </li>
 | 
			
		||||
                          <li> Don't paste HTML here, use only CSS selectors </li>
 | 
			
		||||
                          <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
 | 
			
		||||
                          <li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
 | 
			
		||||
                          <li> Don't paste HTML here, use only CSS and XPath selectors </li>
 | 
			
		||||
                          <li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
 | 
			
		||||
                        </ul>
 | 
			
		||||
                      </span>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
@@ -322,18 +329,25 @@ nav
 | 
			
		||||
                        {{ render_checkbox_field(form.filter_text_added) }}
 | 
			
		||||
                        {{ render_checkbox_field(form.filter_text_replaced) }}
 | 
			
		||||
                        {{ render_checkbox_field(form.filter_text_removed) }}
 | 
			
		||||
                    <span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span>
 | 
			
		||||
                    <span class="pure-form-message-inline">So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br>
 | 
			
		||||
                    <span class="pure-form-message-inline">When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span>
 | 
			
		||||
                    <span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span><br>
 | 
			
		||||
                    <span class="pure-form-message-inline"> So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br>
 | 
			
		||||
                    <span class="pure-form-message-inline"> When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                <fieldset class="pure-control-group">
 | 
			
		||||
                    {{ render_checkbox_field(form.check_unique_lines) }}
 | 
			
		||||
                    <span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                <fieldset class="pure-control-group">
 | 
			
		||||
                    {{ render_checkbox_field(form.remove_duplicate_lines) }}
 | 
			
		||||
                    <span class="pure-form-message-inline">Remove duplicate lines of text</span>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
 | 
			
		||||
                <fieldset class="pure-control-group">
 | 
			
		||||
                    {{ render_checkbox_field(form.sort_text_alphabetically) }}
 | 
			
		||||
                    <span class="pure-form-message-inline">Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below.</span>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                <fieldset class="pure-control-group">
 | 
			
		||||
                    {{ render_checkbox_field(form.check_unique_lines) }}
 | 
			
		||||
                    <span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span>
 | 
			
		||||
                    {{ render_checkbox_field(form.trim_text_whitespace) }}
 | 
			
		||||
                    <span class="pure-form-message-inline">Remove any whitespace before and after each line of text</span>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                <fieldset>
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
@@ -356,10 +370,10 @@ nav
 | 
			
		||||
") }}
 | 
			
		||||
                    <span class="pure-form-message-inline">
 | 
			
		||||
                        <ul>
 | 
			
		||||
                            <li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li>
 | 
			
		||||
                            <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
 | 
			
		||||
                            <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
 | 
			
		||||
                            <li>Changing this will affect the comparison checksum which may trigger an alert</li>
 | 
			
		||||
                            <li>Use the preview/show current tab to see ignores</li>
 | 
			
		||||
                        </ul>
 | 
			
		||||
                </span>
 | 
			
		||||
 | 
			
		||||
@@ -383,7 +397,9 @@ Unavailable") }}
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                <fieldset>
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
                        {{ render_field(form.extract_text, rows=5, placeholder="\d+ online") }}
 | 
			
		||||
                        {{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/
 | 
			
		||||
 or
 | 
			
		||||
keyword") }}
 | 
			
		||||
                        <span class="pure-form-message-inline">
 | 
			
		||||
                    <ul>
 | 
			
		||||
                        <li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
 | 
			
		||||
@@ -403,7 +419,27 @@ Unavailable") }}
 | 
			
		||||
                </fieldset>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
              <div id="text-preview" style="display: none;" >
 | 
			
		||||
                    <script>
 | 
			
		||||
                        const preview_text_edit_filters_url="{{url_for('watch_get_preview_rendered', uuid=uuid)}}";
 | 
			
		||||
                    </script>
 | 
			
		||||
                    <br>
 | 
			
		||||
                    {#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#}
 | 
			
		||||
                    <div class="minitabs-wrapper">
 | 
			
		||||
                      <div class="minitabs-content">
 | 
			
		||||
                          <div id="text-preview-inner" class="monospace-preview">
 | 
			
		||||
                              <p>Loading...</p>
 | 
			
		||||
                          </div>
 | 
			
		||||
                          <div id="text-preview-before-inner" style="display: none;" class="monospace-preview">
 | 
			
		||||
                              <p>Loading...</p>
 | 
			
		||||
                          </div>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {# rendered sub Template #}
 | 
			
		||||
        {% if extra_form_content %}
 | 
			
		||||
            <div class="tab-pane-inner" id="extras_tab">
 | 
			
		||||
@@ -479,6 +515,12 @@ Unavailable") }}
 | 
			
		||||
                        </tr>
 | 
			
		||||
                        </tbody>
 | 
			
		||||
                    </table>
 | 
			
		||||
                    {% if watch.history_n %}
 | 
			
		||||
                        <p>
 | 
			
		||||
                             <a href="{{url_for('watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">Download latest HTML snapshot</a>
 | 
			
		||||
                        </p>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="actions">
 | 
			
		||||
 
 | 
			
		||||
@@ -3,11 +3,13 @@
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <script>
 | 
			
		||||
        const screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid)}}";
 | 
			
		||||
        const triggered_line_numbers = {{ triggered_line_numbers|tojson }};
 | 
			
		||||
        {% if last_error_screenshot %}
 | 
			
		||||
            const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        const highlight_submit_ignore_url = "{{url_for('highlight_submit_ignore_url', uuid=uuid)}}";
 | 
			
		||||
    </script>
 | 
			
		||||
    <script src="{{url_for('static_content', group='js', filename='plugins.js')}}"></script>
 | 
			
		||||
    <script src="{{ url_for('static_content', group='js', filename='diff-overview.js') }}" defer></script>
 | 
			
		||||
    <script src="{{ url_for('static_content', group='js', filename='preview.js') }}" defer></script>
 | 
			
		||||
    <script src="{{ url_for('static_content', group='js', filename='tabs.js') }}" defer></script>
 | 
			
		||||
@@ -67,16 +69,15 @@
 | 
			
		||||
 | 
			
		||||
        <div class="tab-pane-inner" id="text">
 | 
			
		||||
            <div class="snapshot-age">{{ current_version|format_timestamp_timeago }}</div>
 | 
			
		||||
            <span class="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span>
 | 
			
		||||
            <span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span>
 | 
			
		||||
 | 
			
		||||
            <table>
 | 
			
		||||
                <tbody>
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <td id="diff-col" class="highlightable-filter">
 | 
			
		||||
                        {% for row in content %}
 | 
			
		||||
                            <div class="{{ row.classes }}">{{ row.line }}</div>
 | 
			
		||||
                        {% endfor %}
 | 
			
		||||
                        <pre style="border-left: 2px solid #ddd;">
 | 
			
		||||
{{ content }}
 | 
			
		||||
                        </pre>
 | 
			
		||||
                    </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                </tbody>
 | 
			
		||||
 
 | 
			
		||||
@@ -76,7 +76,7 @@
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
                        {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }}
 | 
			
		||||
                        <span class="pure-form-message-inline">When a page contains HTML, but no renderable text appears (empty page), is this considered a change?</span>
 | 
			
		||||
                        <span class="pure-form-message-inline">When a request returns no content, or the HTML does not contain any text, is this considered a change?</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                {% if form.requests.proxy %}
 | 
			
		||||
                    <div class="pure-control-group inline-radio">
 | 
			
		||||
@@ -155,11 +155,13 @@
 | 
			
		||||
                      {{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder="header
 | 
			
		||||
footer
 | 
			
		||||
nav
 | 
			
		||||
.stockticker") }}
 | 
			
		||||
.stockticker
 | 
			
		||||
//*[contains(text(), 'Advertisement')]") }}
 | 
			
		||||
                      <span class="pure-form-message-inline">
 | 
			
		||||
                        <ul>
 | 
			
		||||
                          <li> Remove HTML element(s) by CSS selector before text conversion. </li>
 | 
			
		||||
                          <li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
 | 
			
		||||
                          <li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
 | 
			
		||||
                          <li> Don't paste HTML here, use only CSS and XPath selectors </li>
 | 
			
		||||
                          <li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
 | 
			
		||||
                        </ul>
 | 
			
		||||
                      </span>
 | 
			
		||||
                    </fieldset>
 | 
			
		||||
@@ -170,11 +172,11 @@ nav
 | 
			
		||||
                    <span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br>
 | 
			
		||||
                    <span class="pure-form-message-inline">
 | 
			
		||||
                        <ul>
 | 
			
		||||
                            <li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li>
 | 
			
		||||
                            <li>Note: This is applied globally in addition to the per-watch rules.</li>
 | 
			
		||||
                            <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
 | 
			
		||||
                            <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
 | 
			
		||||
                            <li>Changing this will affect the comparison checksum which may trigger an alert</li>
 | 
			
		||||
                            <li>Use the preview/show current tab to see ignores</li>
 | 
			
		||||
                        </ul>
 | 
			
		||||
                     </span>
 | 
			
		||||
                    </fieldset>
 | 
			
		||||
 
 | 
			
		||||
@@ -78,8 +78,8 @@
 | 
			
		||||
             {% if any_has_restock_price_processor %}
 | 
			
		||||
                <th>Restock & Price</th>
 | 
			
		||||
             {% endif %}
 | 
			
		||||
                <th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Checked <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Changed <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th class="empty-cell"></th>
 | 
			
		||||
            </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
@@ -191,9 +191,9 @@
 | 
			
		||||
                    {% if watch.history_n >= 2 %}
 | 
			
		||||
 | 
			
		||||
                        {%  if is_unviewed %}
 | 
			
		||||
                           <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_next_snapshot_key_to_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">Diff</a>
 | 
			
		||||
                           <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_next_snapshot_key_to_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
 | 
			
		||||
                        {% else %}
 | 
			
		||||
                           <a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">Diff</a>
 | 
			
		||||
                           <a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
 | 
			
		||||
                    {% else %}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import resource
 | 
			
		||||
import time
 | 
			
		||||
from threading import Thread
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# !/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,3 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
from .. import conftest
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								changedetectionio/tests/itemprop_test_examples/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								changedetectionio/tests/itemprop_test_examples/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
# A list of real world examples!
 | 
			
		||||
 | 
			
		||||
Always the price should be 666.66 for our tests
 | 
			
		||||
 | 
			
		||||
see test_restock_itemprop.py::test_special_prop_examples
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										25
									
								
								changedetectionio/tests/itemprop_test_examples/a.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								changedetectionio/tests/itemprop_test_examples/a.txt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
<div class="PriceSection PriceSection_PriceSection__Vx1_Q PriceSection_variantHuge__P9qxg PdpPriceSection"
 | 
			
		||||
     data-testid="price-section"
 | 
			
		||||
     data-optly-product-tile-price-section="true"><span
 | 
			
		||||
        class="PriceRange ProductPrice variant-huge" itemprop="offers"
 | 
			
		||||
        itemscope="" itemtype="http://schema.org/Offer"><div
 | 
			
		||||
        class="VisuallyHidden_VisuallyHidden__VBD83">$155.55</div><span
 | 
			
		||||
        aria-hidden="true" class="Price variant-huge" data-testid="price"
 | 
			
		||||
        itemprop="price"><sup class="sup" data-testid="price-symbol"
 | 
			
		||||
                              itemprop="priceCurrency" content="AUD">$</sup><span
 | 
			
		||||
        class="dollars" data-testid="price-value" itemprop="price"
 | 
			
		||||
        content="155.55">155.55</span><span class="extras"><span class="sup"
 | 
			
		||||
                                                              data-testid="price-sup"></span></span></span></span>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<script type="application/ld+json">{
 | 
			
		||||
                                "@type": "Product",
 | 
			
		||||
                                "@context": "https://schema.org",
 | 
			
		||||
                                "name": "test",
 | 
			
		||||
                                "description": "test",
 | 
			
		||||
                                "offers": {
 | 
			
		||||
                                    "@type": "Offer",
 | 
			
		||||
                                    "priceCurrency": "AUD",
 | 
			
		||||
                                    "price": 155.55
 | 
			
		||||
                                },
 | 
			
		||||
                            }</script>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
from .. import conftest
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
@@ -16,4 +16,4 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
@@ -44,7 +44,7 @@ def test_select_custom(client, live_server, measure_memory_usage):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    # We should see something via proxy
 | 
			
		||||
    assert b'<div class=""> - 0.' in res.data
 | 
			
		||||
    assert b' - 0.' in res.data
 | 
			
		||||
 | 
			
		||||
    #
 | 
			
		||||
    # Now we should see the request in the container logs for "squid-squid-custom" because it will be the only default
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,27 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import json
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks
 | 
			
		||||
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_response():
 | 
			
		||||
    import time
 | 
			
		||||
    data = f"""<html>
 | 
			
		||||
       <body>
 | 
			
		||||
     <h1>Awesome, you made it</h1>
 | 
			
		||||
     yeah the socks request worked
 | 
			
		||||
     </body>
 | 
			
		||||
     </html>
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        f.write(data)
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
def test_socks5(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
    set_response()
 | 
			
		||||
 | 
			
		||||
    # Setup a proxy
 | 
			
		||||
    res = client.post(
 | 
			
		||||
@@ -24,7 +39,10 @@ def test_socks5(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    assert b"Settings updated." in res.data
 | 
			
		||||
 | 
			
		||||
    test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '')
 | 
			
		||||
    # Because the socks server should connect back to us
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True) + f"?socks-test-tag={os.getenv('SOCKSTEST', '')}"
 | 
			
		||||
    test_url = test_url.replace('localhost.localdomain', 'cdio')
 | 
			
		||||
    test_url = test_url.replace('localhost', 'cdio')
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
@@ -60,4 +78,25 @@ def test_socks5(client, live_server, measure_memory_usage):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Should see the proper string
 | 
			
		||||
    assert "+0200:".encode('utf-8') in res.data
 | 
			
		||||
    assert "Awesome, you made it".encode('utf-8') in res.data
 | 
			
		||||
 | 
			
		||||
    # PROXY CHECKER WIDGET CHECK - this needs more checking
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("check_proxies.start_check", uuid=uuid),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    # It's probably already finished super fast :(
 | 
			
		||||
    #assert b"RUNNING" in res.data
 | 
			
		||||
    
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("check_proxies.get_recheck_status", uuid=uuid),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"OK" in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,32 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_response():
 | 
			
		||||
    import time
 | 
			
		||||
    data = f"""<html>
 | 
			
		||||
       <body>
 | 
			
		||||
     <h1>Awesome, you made it</h1>
 | 
			
		||||
     yeah the socks request worked
 | 
			
		||||
     </body>
 | 
			
		||||
     </html>
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        f.write(data)
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
# should be proxies.json mounted from run_proxy_tests.sh already
 | 
			
		||||
# -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json
 | 
			
		||||
def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '')
 | 
			
		||||
    set_response()
 | 
			
		||||
    # Because the socks server should connect back to us
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True) + f"?socks-test-tag={os.getenv('SOCKSTEST', '')}"
 | 
			
		||||
    test_url = test_url.replace('localhost.localdomain', 'cdio')
 | 
			
		||||
    test_url = test_url.replace('localhost', 'cdio')
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("settings_page"))
 | 
			
		||||
    assert b'name="requests-proxy" type="radio" value="socks5proxy"' in res.data
 | 
			
		||||
@@ -49,4 +65,4 @@ def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Should see the proper string
 | 
			
		||||
    assert "+0200:".encode('utf-8') in res.data
 | 
			
		||||
    assert "Awesome, you made it".encode('utf-8') in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,3 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
from .. import conftest
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
 | 
			
		||||
from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client, wait_for_notification_endpoint_output
 | 
			
		||||
from changedetectionio.notification import (
 | 
			
		||||
    default_notification_body,
 | 
			
		||||
    default_notification_format,
 | 
			
		||||
@@ -94,7 +94,7 @@ def test_restock_detection(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b'not-in-stock' not in res.data
 | 
			
		||||
 | 
			
		||||
    # We should have a notification
 | 
			
		||||
    time.sleep(2)
 | 
			
		||||
    wait_for_notification_endpoint_output()
 | 
			
		||||
    assert os.path.isfile("test-datastore/notification.txt"), "Notification received"
 | 
			
		||||
    os.unlink("test-datastore/notification.txt")
 | 
			
		||||
 | 
			
		||||
@@ -103,6 +103,7 @@ def test_restock_detection(client, live_server, measure_memory_usage):
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(5)
 | 
			
		||||
    assert not os.path.isfile("test-datastore/notification.txt"), "No notification should have fired when it went OUT OF STOCK by default"
 | 
			
		||||
 | 
			
		||||
    # BUT we should see that it correctly shows "not in stock"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import asyncio
 | 
			
		||||
from aiosmtpd.controller import Controller
 | 
			
		||||
from aiosmtpd.smtp import SMTP
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import os.path
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output
 | 
			
		||||
from changedetectionio import html_tools
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -39,9 +39,8 @@ def test_setup(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
def test_check_removed_line_contains_trigger(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
    set_original()
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
@@ -78,6 +77,8 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory
 | 
			
		||||
 | 
			
		||||
    # The trigger line is REMOVED,  this should trigger
 | 
			
		||||
    set_original(excluding='The golden line')
 | 
			
		||||
 | 
			
		||||
    # Check in the processor here what's going on, its triggering empty-reply and no change.
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
@@ -112,7 +113,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
 | 
			
		||||
              "application-notification_body": 'triggered text was -{{triggered_text}}-',
 | 
			
		||||
              "application-notification_body": 'triggered text was -{{triggered_text}}- 网站监测 内容更新了',
 | 
			
		||||
              # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
 | 
			
		||||
              "application-notification_urls": test_notification_url,
 | 
			
		||||
              "application-minutes_between_check": 180,
 | 
			
		||||
@@ -153,6 +154,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
 | 
			
		||||
    # A line thats not the trigger should not trigger anything
 | 
			
		||||
    res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'1 watches queued for rechecking.' in res.data
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
@@ -165,12 +167,12 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # Takes a moment for apprise to fire
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
    wait_for_notification_endpoint_output()
 | 
			
		||||
    assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file"
 | 
			
		||||
    with open("test-datastore/notification.txt", 'r') as f:
 | 
			
		||||
        response= f.read()
 | 
			
		||||
        assert '-Oh yes please-' in response
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/notification.txt", 'rb') as f:
 | 
			
		||||
        response = f.read()
 | 
			
		||||
        assert b'-Oh yes please-' in response
 | 
			
		||||
        assert '网站监测 内容更新了'.encode('utf-8') in response
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
@@ -69,6 +69,12 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
 | 
			
		||||
    # Check the 'get latest snapshot works'
 | 
			
		||||
    res = client.get(url_for("watch_get_latest_html", uuid=uuid))
 | 
			
		||||
    assert b'which has this one new line' in res.data
 | 
			
		||||
 | 
			
		||||
    # Now something should be ready, indicated by having a 'unviewed' class
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
@@ -86,7 +92,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    assert expected_url.encode('utf-8') in res.data
 | 
			
		||||
 | 
			
		||||
    # Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times
 | 
			
		||||
    res = client.get(url_for("diff_history_page", uuid="first"))
 | 
			
		||||
    res = client.get(url_for("diff_history_page", uuid=uuid))
 | 
			
		||||
    assert b'selected=""' in res.data, "Confirm diff history page loaded"
 | 
			
		||||
 | 
			
		||||
    # Check the [preview] pulls the right one
 | 
			
		||||
@@ -143,7 +149,6 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    client.get(url_for("clear_watch_history", uuid=uuid))
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
from .util import set_original_response, live_server_setup, wait_for_all_checks
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 | 
			
		||||
from ..html_tools import *
 | 
			
		||||
from .util import live_server_setup
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_setup(live_server):
 | 
			
		||||
@@ -87,6 +87,9 @@ def test_element_removal_output():
 | 
			
		||||
     Some initial text<br>
 | 
			
		||||
     <p>across multiple lines</p>
 | 
			
		||||
     <div id="changetext">Some text that changes</div>
 | 
			
		||||
     <div>Some text should be matched by xPath // selector</div>
 | 
			
		||||
     <div>Some text should be matched by xPath selector</div>
 | 
			
		||||
     <div>Some text should be matched by xPath1 selector</div>
 | 
			
		||||
     </body>
 | 
			
		||||
    <footer>
 | 
			
		||||
    <p>Footer</p>
 | 
			
		||||
@@ -94,7 +97,16 @@ def test_element_removal_output():
 | 
			
		||||
     </html>
 | 
			
		||||
    """
 | 
			
		||||
    html_blob = element_removal(
 | 
			
		||||
        ["header", "footer", "nav", "#changetext"], html_content=content
 | 
			
		||||
      [
 | 
			
		||||
        "header",
 | 
			
		||||
        "footer",
 | 
			
		||||
        "nav",
 | 
			
		||||
        "#changetext",
 | 
			
		||||
        "//*[contains(text(), 'xPath // selector')]",
 | 
			
		||||
        "xpath://*[contains(text(), 'xPath selector')]",
 | 
			
		||||
        "xpath1://*[contains(text(), 'xPath1 selector')]"
 | 
			
		||||
      ],
 | 
			
		||||
      html_content=content
 | 
			
		||||
    )
 | 
			
		||||
    text = get_text(html_blob)
 | 
			
		||||
    assert (
 | 
			
		||||
@@ -107,12 +119,10 @@ across multiple lines
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_element_removal_full(client, live_server, measure_memory_usage):
 | 
			
		||||
    sleep_time_for_fetch_thread = 3
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    set_original_response()
 | 
			
		||||
 | 
			
		||||
    # 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)
 | 
			
		||||
@@ -120,7 +130,8 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
 | 
			
		||||
        url_for("import_page"), data={"urls": test_url}, follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Goto the edit page, add the filter data
 | 
			
		||||
    # Not sure why \r needs to be added - absent of the #changetext this is not necessary
 | 
			
		||||
    subtractive_selectors_data = "header\r\nfooter\r\nnav\r\n#changetext"
 | 
			
		||||
@@ -136,6 +147,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
 | 
			
		||||
        follow_redirects=True,
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Check it saved
 | 
			
		||||
    res = client.get(
 | 
			
		||||
@@ -144,10 +156,10 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert bytes(subtractive_selectors_data.encode("utf-8")) in res.data
 | 
			
		||||
 | 
			
		||||
    # Trigger a check
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'1 watches queued for rechecking.' in res.data
 | 
			
		||||
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # so that we set the state to 'unviewed' after all the edits
 | 
			
		||||
    client.get(url_for("diff_history_page", uuid="first"))
 | 
			
		||||
@@ -156,10 +168,11 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
 | 
			
		||||
    set_modified_response()
 | 
			
		||||
 | 
			
		||||
    # Trigger a check
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'1 watches queued for rechecking.' in res.data
 | 
			
		||||
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # There should not be an unviewed change, as changes should be removed
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
# coding=utf-8
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -38,6 +38,11 @@ def test_check_encoding_detection(client, live_server, measure_memory_usage):
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Content type recording worked
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == "text/html"
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
@@ -71,7 +71,7 @@ def test_setup(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
def test_check_filter_multiline(client, live_server, measure_memory_usage):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
   # live_server_setup(live_server)
 | 
			
		||||
    set_multiline_response()
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
@@ -115,9 +115,9 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage):
 | 
			
		||||
    # Plaintext that doesnt look like a regex should match also
 | 
			
		||||
    assert b'and this should be' in res.data
 | 
			
		||||
 | 
			
		||||
    assert b'<div class="">Something' in res.data
 | 
			
		||||
    assert b'<div class="">across 6 billion multiple' in res.data
 | 
			
		||||
    assert b'<div class="">lines' in res.data
 | 
			
		||||
    assert b'Something' in res.data
 | 
			
		||||
    assert b'across 6 billion multiple' in res.data
 | 
			
		||||
    assert b'lines' in res.data
 | 
			
		||||
 | 
			
		||||
    # but the last one, which also says 'lines' shouldnt be here (non-greedy match checking)
 | 
			
		||||
    assert b'aaand something lines' not in res.data
 | 
			
		||||
@@ -183,20 +183,19 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Class will be blank for now because the frontend didnt apply the diff
 | 
			
		||||
    assert b'<div class="">1000 online' in res.data
 | 
			
		||||
    assert b'1000 online' in res.data
 | 
			
		||||
 | 
			
		||||
    # All regex matching should be here
 | 
			
		||||
    assert b'<div class="">2000 online' in res.data
 | 
			
		||||
    assert b'2000 online' in res.data
 | 
			
		||||
 | 
			
		||||
    # Both regexs should be here
 | 
			
		||||
    assert b'<div class="">80 guests' in res.data
 | 
			
		||||
    assert b'80 guests' in res.data
 | 
			
		||||
 | 
			
		||||
    # Regex with flag handling should be here
 | 
			
		||||
    assert b'<div class="">SomeCase insensitive 3456' in res.data
 | 
			
		||||
    assert b'SomeCase insensitive 3456' in res.data
 | 
			
		||||
 | 
			
		||||
    # Singular group from /somecase insensitive (345\d)/i
 | 
			
		||||
    assert b'<div class="">3456' in res.data
 | 
			
		||||
    assert b'3456' in res.data
 | 
			
		||||
 | 
			
		||||
    # Regex with multiline flag handling should be here
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,10 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
# https://www.reddit.com/r/selfhosted/comments/wa89kp/comment/ii3a4g7/?context=3
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import set_original_response, live_server_setup
 | 
			
		||||
from .util import set_original_response, live_server_setup, wait_for_notification_endpoint_output
 | 
			
		||||
from changedetectionio.model import App
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -102,14 +102,15 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
    wait_for_notification_endpoint_output()
 | 
			
		||||
 | 
			
		||||
    # Shouldn't exist, shouldn't have fired
 | 
			
		||||
    assert not os.path.isfile("test-datastore/notification.txt")
 | 
			
		||||
    # Now the filter should exist
 | 
			
		||||
    set_response_with_filter()
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
 | 
			
		||||
    wait_for_notification_endpoint_output()
 | 
			
		||||
 | 
			
		||||
    assert os.path.isfile("test-datastore/notification.txt")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks
 | 
			
		||||
from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks, \
 | 
			
		||||
    wait_for_notification_endpoint_output
 | 
			
		||||
from changedetectionio.model import App
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -26,6 +28,12 @@ def run_filter_test(client, live_server, content_filter):
 | 
			
		||||
    # Response WITHOUT the filter ID element
 | 
			
		||||
    set_original_response()
 | 
			
		||||
 | 
			
		||||
    # Goto the edit page, add our ignore text
 | 
			
		||||
    notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
 | 
			
		||||
    # cleanup for the next
 | 
			
		||||
    client.get(
 | 
			
		||||
        url_for("form_delete", uuid="all"),
 | 
			
		||||
@@ -34,83 +42,92 @@ def run_filter_test(client, live_server, content_filter):
 | 
			
		||||
    if os.path.isfile("test-datastore/notification.txt"):
 | 
			
		||||
        os.unlink("test-datastore/notification.txt")
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": test_url, "tags": ''},
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data={"urls": test_url},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"Watch added" in res.data
 | 
			
		||||
 | 
			
		||||
    # Give the thread time to pick up the first version
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Goto the edit page, add our ignore text
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    url = url_for('test_notification_endpoint', _external=True)
 | 
			
		||||
    notification_url = url.replace('http', 'json')
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
 | 
			
		||||
    print(">>>> Notification URL: " + notification_url)
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure"
 | 
			
		||||
 | 
			
		||||
    # Just a regular notification setting, this will be used by the special 'filter not found' notification
 | 
			
		||||
    notification_form_data = {"notification_urls": notification_url,
 | 
			
		||||
                              "notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
 | 
			
		||||
                              "notification_body": "BASE URL: {{base_url}}\n"
 | 
			
		||||
                                                   "Watch URL: {{watch_url}}\n"
 | 
			
		||||
                                                   "Watch UUID: {{watch_uuid}}\n"
 | 
			
		||||
                                                   "Watch title: {{watch_title}}\n"
 | 
			
		||||
                                                   "Watch tag: {{watch_tag}}\n"
 | 
			
		||||
                                                   "Preview: {{preview_url}}\n"
 | 
			
		||||
                                                   "Diff URL: {{diff_url}}\n"
 | 
			
		||||
                                                   "Snapshot: {{current_snapshot}}\n"
 | 
			
		||||
                                                   "Diff: {{diff}}\n"
 | 
			
		||||
                                                   "Diff Full: {{diff_full}}\n"
 | 
			
		||||
                                                   "Diff as Patch: {{diff_patch}}\n"
 | 
			
		||||
                                                   ":-)",
 | 
			
		||||
                              "notification_format": "Text"}
 | 
			
		||||
    watch_data = {"notification_urls": notification_url,
 | 
			
		||||
                  "notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
 | 
			
		||||
                  "notification_body": "BASE URL: {{base_url}}\n"
 | 
			
		||||
                                       "Watch URL: {{watch_url}}\n"
 | 
			
		||||
                                       "Watch UUID: {{watch_uuid}}\n"
 | 
			
		||||
                                       "Watch title: {{watch_title}}\n"
 | 
			
		||||
                                       "Watch tag: {{watch_tag}}\n"
 | 
			
		||||
                                       "Preview: {{preview_url}}\n"
 | 
			
		||||
                                       "Diff URL: {{diff_url}}\n"
 | 
			
		||||
                                       "Snapshot: {{current_snapshot}}\n"
 | 
			
		||||
                                       "Diff: {{diff}}\n"
 | 
			
		||||
                                       "Diff Full: {{diff_full}}\n"
 | 
			
		||||
                                       "Diff as Patch: {{diff_patch}}\n"
 | 
			
		||||
                                       ":-)",
 | 
			
		||||
                  "notification_format": "Text",
 | 
			
		||||
                  "fetch_backend": "html_requests",
 | 
			
		||||
                  "filter_failure_notification_send": 'y',
 | 
			
		||||
                  "headers": "",
 | 
			
		||||
                  "tags": "my tag",
 | 
			
		||||
                  "title": "my title 123",
 | 
			
		||||
                  "time_between_check-hours": 5,  # So that the queue runner doesnt also put it in
 | 
			
		||||
                  "url": test_url,
 | 
			
		||||
                  }
 | 
			
		||||
 | 
			
		||||
    notification_form_data.update({
 | 
			
		||||
        "url": test_url,
 | 
			
		||||
        "tags": "my tag",
 | 
			
		||||
        "title": "my title 123",
 | 
			
		||||
        "headers": "",
 | 
			
		||||
        "filter_failure_notification_send": 'y',
 | 
			
		||||
        "include_filters": content_filter,
 | 
			
		||||
        "fetch_backend": "html_requests"})
 | 
			
		||||
 | 
			
		||||
    # A POST here will also reset the filter failure counter (filter_failure_notification_threshold_attempts)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data=notification_form_data,
 | 
			
		||||
        url_for("edit_page", uuid=uuid),
 | 
			
		||||
        data=watch_data,
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure"
 | 
			
		||||
 | 
			
		||||
    # Now the notification should not exist, because we didnt reach the threshold
 | 
			
		||||
    # Now add a filter, because recheck hours == 5, ONLY pressing of the [edit] or [recheck all] should trigger
 | 
			
		||||
    watch_data['include_filters'] = content_filter
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid=uuid),
 | 
			
		||||
        data=watch_data,
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
 | 
			
		||||
    # It should have checked once so far and given this error (because we hit SAVE)
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    assert not os.path.isfile("test-datastore/notification.txt")
 | 
			
		||||
 | 
			
		||||
    # Hitting [save] would have triggered a recheck, and we have a filter, so this would be ONE failure
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 1, "Should have been checked once"
 | 
			
		||||
 | 
			
		||||
    # recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented)
 | 
			
		||||
    for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT-2):
 | 
			
		||||
    # Add 4 more checks
 | 
			
		||||
    checked = 0
 | 
			
		||||
    ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)
 | 
			
		||||
    for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2):
 | 
			
		||||
        checked += 1
 | 
			
		||||
        client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
        time.sleep(2) # delay for apprise to fire
 | 
			
		||||
        assert not os.path.isfile("test-datastore/notification.txt"), f"test-datastore/notification.txt should not exist - Attempt {i} when threshold is {App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT}"
 | 
			
		||||
 | 
			
		||||
    # We should see something in the frontend
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'Warning, no filters were found' in res.data
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        assert b'Warning, no filters were found' in res.data
 | 
			
		||||
        assert not os.path.isfile("test-datastore/notification.txt")
 | 
			
		||||
        time.sleep(1)
 | 
			
		||||
        
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 5
 | 
			
		||||
 | 
			
		||||
    time.sleep(2)
 | 
			
		||||
    # One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    time.sleep(2)  # delay for apprise to fire
 | 
			
		||||
    wait_for_notification_endpoint_output()
 | 
			
		||||
 | 
			
		||||
    # Now it should exist and contain our "filter not found" alert
 | 
			
		||||
    assert os.path.isfile("test-datastore/notification.txt")
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/notification.txt", 'r') as f:
 | 
			
		||||
        notification = f.read()
 | 
			
		||||
 | 
			
		||||
@@ -123,10 +140,11 @@ def run_filter_test(client, live_server, content_filter):
 | 
			
		||||
    set_response_with_filter()
 | 
			
		||||
 | 
			
		||||
    # Try several times, it should NOT have 'filter not found'
 | 
			
		||||
    for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT):
 | 
			
		||||
    for i in range(0, ATTEMPT_THRESHOLD_SETTING + 2):
 | 
			
		||||
        client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    wait_for_notification_endpoint_output()
 | 
			
		||||
    # It should have sent a notification, but..
 | 
			
		||||
    assert os.path.isfile("test-datastore/notification.txt")
 | 
			
		||||
    # but it should not contain the info about a failed filter (because there was none in this case)
 | 
			
		||||
@@ -135,9 +153,6 @@ def run_filter_test(client, live_server, content_filter):
 | 
			
		||||
    assert not 'CSS/xPath filter was not present in the page' in notification
 | 
			
		||||
 | 
			
		||||
    # Re #1247 - All tokens got replaced correctly in the notification
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    # UUID is correct, but notification contains tag uuid as UUIID wtf
 | 
			
		||||
    assert uuid in notification
 | 
			
		||||
 | 
			
		||||
    # cleanup for the next
 | 
			
		||||
@@ -152,9 +167,11 @@ def test_setup(live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage):
 | 
			
		||||
#    live_server_setup(live_server)
 | 
			
		||||
    run_filter_test(client, live_server,'#nope-doesnt-exist')
 | 
			
		||||
 | 
			
		||||
def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage):
 | 
			
		||||
#    live_server_setup(live_server)
 | 
			
		||||
    run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]')
 | 
			
		||||
 | 
			
		||||
# Test that notification is never sent
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
import os
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
"""Test suite for the method to extract text from an html string"""
 | 
			
		||||
from ..html_tools import html_to_text
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
@@ -23,7 +23,7 @@ def set_original_ignore_response():
 | 
			
		||||
        f.write(test_return_data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_highlight_ignore(client, live_server, measure_memory_usage):
 | 
			
		||||
def test_ignore(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
    set_original_ignore_response()
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
@@ -51,9 +51,9 @@ def test_highlight_ignore(client, live_server, measure_memory_usage):
 | 
			
		||||
    # Should return a link
 | 
			
		||||
    assert b'href' in res.data
 | 
			
		||||
 | 
			
		||||
    # And it should register in the preview page
 | 
			
		||||
    # It should not be in the preview anymore
 | 
			
		||||
    res = client.get(url_for("preview_page", uuid=uuid))
 | 
			
		||||
    assert b'<div class="ignored">oh yeah 456' in res.data
 | 
			
		||||
    assert b'<div class="ignored">oh yeah 456' not in res.data
 | 
			
		||||
 | 
			
		||||
    # Should be in base.html
 | 
			
		||||
    assert b'csrftoken' in res.data
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
from . util import live_server_setup
 | 
			
		||||
from changedetectionio import html_tools
 | 
			
		||||
@@ -33,13 +33,17 @@ def test_strip_regex_text_func():
 | 
			
		||||
 | 
			
		||||
    stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines)
 | 
			
		||||
 | 
			
		||||
    assert b"but 1 lines" in stripped_content
 | 
			
		||||
    assert b"igNORe-cAse text" not in stripped_content
 | 
			
		||||
    assert b"but 1234 lines" not in stripped_content
 | 
			
		||||
    assert b"really" not in stripped_content
 | 
			
		||||
    assert b"not this" not in stripped_content
 | 
			
		||||
    assert "but 1 lines" in stripped_content
 | 
			
		||||
    assert "igNORe-cAse text" not in stripped_content
 | 
			
		||||
    assert "but 1234 lines" not in stripped_content
 | 
			
		||||
    assert "really" not in stripped_content
 | 
			
		||||
    assert "not this" not in stripped_content
 | 
			
		||||
 | 
			
		||||
    # Check line number reporting
 | 
			
		||||
    stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines, mode="line numbers")
 | 
			
		||||
    assert stripped_content == [2, 5, 6, 7, 8, 10]
 | 
			
		||||
 | 
			
		||||
    # Check that linefeeds are preserved when there are is no matching ignores
 | 
			
		||||
    content = "some text\n\nand other text\n"
 | 
			
		||||
    stripped_content = html_tools.strip_ignore_text(content, ignore_lines)
 | 
			
		||||
    assert content == stripped_content
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
@@ -22,10 +22,15 @@ def test_strip_text_func():
 | 
			
		||||
    ignore_lines = ["sometimes"]
 | 
			
		||||
 | 
			
		||||
    stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines)
 | 
			
		||||
    assert "sometimes" not in stripped_content
 | 
			
		||||
    assert "Some content" in stripped_content
 | 
			
		||||
 | 
			
		||||
    assert b"sometimes" not in stripped_content
 | 
			
		||||
    assert b"Some content" in stripped_content
 | 
			
		||||
    # Check that line feeds dont get chewed up when something is found
 | 
			
		||||
    test_content = "Some initial text\n\nWhich is across multiple lines\n\nZZZZz\n\n\nSo let's see what happens."
 | 
			
		||||
    ignore = ['something irrelevent but just to check', 'XXXXX', 'YYYYY', 'ZZZZZ']
 | 
			
		||||
 | 
			
		||||
    stripped_content = html_tools.strip_ignore_text(test_content, ignore)
 | 
			
		||||
    assert stripped_content == "Some initial text\n\nWhich is across multiple lines\n\n\n\nSo let's see what happens."
 | 
			
		||||
 | 
			
		||||
def set_original_ignore_response():
 | 
			
		||||
    test_return_data = """<html>
 | 
			
		||||
@@ -79,14 +84,14 @@ def set_modified_ignore_response():
 | 
			
		||||
        f.write(test_return_data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Ignore text now just removes it entirely, is a LOT more simpler code this way
 | 
			
		||||
 | 
			
		||||
def test_check_ignore_text_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    # Use a mix of case in ZzZ to prove it works case-insensitive.
 | 
			
		||||
    ignore_text = "XXXXX\r\nYYYYY\r\nzZzZZ\r\nnew ignore stuff"
 | 
			
		||||
    set_original_ignore_response()
 | 
			
		||||
 | 
			
		||||
    # 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)
 | 
			
		||||
@@ -141,8 +146,6 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Just to be sure.. set a regular modified change..
 | 
			
		||||
    set_modified_original_ignore_response()
 | 
			
		||||
    client.get(url_for("form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
@@ -151,21 +154,19 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # Check the preview/highlighter, we should be able to see what we ignored, but it should be highlighted
 | 
			
		||||
    # We only introduce the "modified" content that includes what we ignore so we can prove the newest version also displays
 | 
			
		||||
    # at /preview
 | 
			
		||||
    res = client.get(url_for("preview_page", uuid="first"))
 | 
			
		||||
    # We should be able to see what we ignored
 | 
			
		||||
    assert b'<div class="ignored">new ignore stuff' in res.data
 | 
			
		||||
 | 
			
		||||
    # SHOULD BE be in the preview, it was added in set_modified_original_ignore_response()
 | 
			
		||||
    # and we have "new ignore stuff" in ignore_text
 | 
			
		||||
    # it is only ignored, it is not removed (it will be highlighted too)
 | 
			
		||||
    assert b'new ignore stuff' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 | 
			
		||||
# When adding some ignore text, it should not trigger a change, even if something else on that line changes
 | 
			
		||||
def test_check_global_ignore_text_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    ignore_text = "XXXXX\r\nYYYYY\r\nZZZZZ"
 | 
			
		||||
    set_original_ignore_response()
 | 
			
		||||
 | 
			
		||||
@@ -174,6 +175,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data={
 | 
			
		||||
            "requests-time_between_check-minutes": 180,
 | 
			
		||||
            "application-ignore_whitespace": "y",
 | 
			
		||||
            "application-global_ignore_text": ignore_text,
 | 
			
		||||
            'application-fetch_backend': "html_requests"
 | 
			
		||||
        },
 | 
			
		||||
@@ -194,9 +196,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Goto the edit page of the item, add our ignore text
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    #Adding some ignore text should not trigger a change
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid="first"),
 | 
			
		||||
        data={"ignore_text": "something irrelevent but just to check", "url": test_url, 'fetch_backend': "html_requests"},
 | 
			
		||||
@@ -212,20 +212,15 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
 | 
			
		||||
 | 
			
		||||
    # 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)
 | 
			
		||||
 | 
			
		||||
    # so that we are sure everything is viewed and in a known 'nothing changed' state
 | 
			
		||||
    res = client.get(url_for("diff_history_page", uuid="first"))
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class), adding random ignore text should not cause a change
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
#####
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    #  Make a change which includes the ignore text
 | 
			
		||||
    # Make a change which includes the ignore text, it should be ignored and no 'change' triggered
 | 
			
		||||
    # It adds text with "ZZZZzzzz" and "ZZZZ" is in the ignore list
 | 
			
		||||
    set_modified_ignore_response()
 | 
			
		||||
 | 
			
		||||
    # Trigger a check
 | 
			
		||||
@@ -235,6 +230,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
"""Test suite for the render/not render anchor tag content functionality"""
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import io
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
# coding=utf-8
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
@@ -499,7 +499,7 @@ def test_correct_header_detect(client, live_server, measure_memory_usage):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b'"hello": 123,' in res.data
 | 
			
		||||
    assert b'"world": 123</div>' in res.data
 | 
			
		||||
    assert b'"world": 123' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										78
									
								
								changedetectionio/tests/test_live_preview.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								changedetectionio/tests/test_live_preview.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,78 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_response():
 | 
			
		||||
 | 
			
		||||
    data = f"""<html>
 | 
			
		||||
       <body>Awesome, you made it<br>
 | 
			
		||||
yeah the socks request worked<br>
 | 
			
		||||
something to ignore<br>
 | 
			
		||||
something to trigger<br>
 | 
			
		||||
     </body>
 | 
			
		||||
     </html>
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        f.write(data)
 | 
			
		||||
 | 
			
		||||
def test_content_filter_live_preview(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
    set_response()
 | 
			
		||||
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("form_quick_watch_add"),
 | 
			
		||||
        data={"url": test_url, "tags": ''},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    uuid = extract_UUID_from_client(client)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("edit_page", uuid=uuid),
 | 
			
		||||
        data={
 | 
			
		||||
            "include_filters": "",
 | 
			
		||||
            "fetch_backend": 'html_requests',
 | 
			
		||||
            "ignore_text": "something to ignore",
 | 
			
		||||
            "trigger_text": "something to trigger",
 | 
			
		||||
            "url": test_url,
 | 
			
		||||
        },
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # The endpoint is a POST and accepts the form values to override the watch preview
 | 
			
		||||
    import json
 | 
			
		||||
 | 
			
		||||
    # DEFAULT OUTPUT WITHOUT ANYTHING UPDATED/CHANGED - SHOULD SEE THE WATCH DEFAULTS
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("watch_get_preview_rendered", uuid=uuid)
 | 
			
		||||
    )
 | 
			
		||||
    default_return = json.loads(res.data.decode('utf-8'))
 | 
			
		||||
    assert default_return.get('after_filter')
 | 
			
		||||
    assert default_return.get('before_filter')
 | 
			
		||||
    assert default_return.get('ignore_line_numbers') == [3] # "something to ignore" line 3
 | 
			
		||||
    assert default_return.get('trigger_line_numbers') == [4] # "something to trigger" line 4
 | 
			
		||||
 | 
			
		||||
    # SEND AN UPDATE AND WE SHOULD SEE THE OUTPUT CHANGE SO WE KNOW TO HIGHLIGHT NEW STUFF
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("watch_get_preview_rendered", uuid=uuid),
 | 
			
		||||
        data={
 | 
			
		||||
            "include_filters": "",
 | 
			
		||||
            "fetch_backend": 'html_requests',
 | 
			
		||||
            "ignore_text": "sOckS", # Also be sure case insensitive works
 | 
			
		||||
            "trigger_text": "AweSOme",
 | 
			
		||||
            "url": test_url,
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    reply = json.loads(res.data.decode('utf-8'))
 | 
			
		||||
    assert reply.get('after_filter')
 | 
			
		||||
    assert reply.get('before_filter')
 | 
			
		||||
    assert reply.get('ignore_line_numbers') == [2]  # Ignored - "socks" on line 2
 | 
			
		||||
    assert reply.get('trigger_line_numbers') == [1]  # Triggers "Awesome" in line 1
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user