mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-11-04 00:27:48 +00:00 
			
		
		
		
	Compare commits
	
		
			21 Commits
		
	
	
		
			0.49.5
			...
			regression
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					bfa4482fb8 | ||
| 
						 | 
					a00e69abed | ||
| 
						 | 
					8f9c46bd3f | ||
| 
						 | 
					97291ce6d0 | ||
| 
						 | 
					f689e5418e | ||
| 
						 | 
					f751f0b0ef | ||
| 
						 | 
					ea9ba3bb2e | ||
| 
						 | 
					c7ffebce2a | ||
| 
						 | 
					54b7c070f7 | ||
| 
						 | 
					6c1b687cd1 | ||
| 
						 | 
					e850540a91 | ||
| 
						 | 
					d4bc9dfc50 | ||
| 
						 | 
					f26ea55e9c | ||
| 
						 | 
					b53e1985ac | ||
| 
						 | 
					302ef80d95 | ||
| 
						 | 
					5b97c29714 | ||
| 
						 | 
					64075c87ee | ||
| 
						 | 
					d58a71cffc | ||
| 
						 | 
					036b006226 | ||
| 
						 | 
					f29f89d078 | ||
| 
						 | 
					289f118581 | 
@@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
 | 
			
		||||
 | 
			
		||||
__version__ = '0.49.5'
 | 
			
		||||
__version__ = '0.49.9'
 | 
			
		||||
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
from json.decoder import JSONDecodeError
 | 
			
		||||
@@ -19,7 +19,6 @@ from changedetectionio import store
 | 
			
		||||
from changedetectionio.flask_app import changedetection_app
 | 
			
		||||
from loguru import logger
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Only global so we can access it in the signal handler
 | 
			
		||||
app = None
 | 
			
		||||
datastore = None
 | 
			
		||||
@@ -29,8 +28,6 @@ def get_version():
 | 
			
		||||
 | 
			
		||||
# Parent wrapper or OS sends us a SIGTERM/SIGINT, do everything required for a clean shutdown
 | 
			
		||||
def sigshutdown_handler(_signo, _stack_frame):
 | 
			
		||||
    global app
 | 
			
		||||
    global datastore
 | 
			
		||||
    name = signal.Signals(_signo).name
 | 
			
		||||
    logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown')
 | 
			
		||||
    datastore.sync_to_json()
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										62
									
								
								changedetectionio/api/Import.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								changedetectionio/api/Import.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
import os
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
from flask_restful import abort, Resource
 | 
			
		||||
from flask import request
 | 
			
		||||
import validators
 | 
			
		||||
from . import auth
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Import(Resource):
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
        # datastore is a black box dependency
 | 
			
		||||
        self.datastore = kwargs['datastore']
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    def post(self):
 | 
			
		||||
        """
 | 
			
		||||
        @api {post} /api/v1/import Import a list of watched URLs
 | 
			
		||||
        @apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag  id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line.
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a"
 | 
			
		||||
        @apiName Import
 | 
			
		||||
        @apiGroup Watch
 | 
			
		||||
        @apiSuccess (200) {List} OK List of watch UUIDs added
 | 
			
		||||
        @apiSuccess (500) {String} ERR Some other error
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        extras = {}
 | 
			
		||||
 | 
			
		||||
        if request.args.get('proxy'):
 | 
			
		||||
            plist = self.datastore.proxy_list
 | 
			
		||||
            if not request.args.get('proxy') in plist:
 | 
			
		||||
                return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400
 | 
			
		||||
            else:
 | 
			
		||||
                extras['proxy'] = request.args.get('proxy')
 | 
			
		||||
 | 
			
		||||
        dedupe = strtobool(request.args.get('dedupe', 'true'))
 | 
			
		||||
 | 
			
		||||
        tags = request.args.get('tag')
 | 
			
		||||
        tag_uuids = request.args.get('tag_uuids')
 | 
			
		||||
 | 
			
		||||
        if tag_uuids:
 | 
			
		||||
            tag_uuids = tag_uuids.split(',')
 | 
			
		||||
 | 
			
		||||
        urls = request.get_data().decode('utf8').splitlines()
 | 
			
		||||
        added = []
 | 
			
		||||
        allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
 | 
			
		||||
        for url in urls:
 | 
			
		||||
            url = url.strip()
 | 
			
		||||
            if not len(url):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            # If hosts that only contain alphanumerics are allowed ("localhost" for example)
 | 
			
		||||
            if not validators.url(url, simple_host=allow_simplehost):
 | 
			
		||||
                return f"Invalid or unsupported URL - {url}", 400
 | 
			
		||||
 | 
			
		||||
            if dedupe and self.datastore.url_exists(url):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids)
 | 
			
		||||
            added.append(new_uuid)
 | 
			
		||||
 | 
			
		||||
        return added
 | 
			
		||||
							
								
								
									
										51
									
								
								changedetectionio/api/Search.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								changedetectionio/api/Search.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,51 @@
 | 
			
		||||
from flask_restful import Resource, abort
 | 
			
		||||
from flask import request
 | 
			
		||||
from . import auth
 | 
			
		||||
 | 
			
		||||
class Search(Resource):
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
        # datastore is a black box dependency
 | 
			
		||||
        self.datastore = kwargs['datastore']
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    def get(self):
 | 
			
		||||
        """
 | 
			
		||||
        @api {get} /api/v1/search Search for watches
 | 
			
		||||
        @apiDescription Search watches by URL or title text
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl "http://localhost:5000/api/v1/search?q=https://example.com/page1" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
            curl "http://localhost:5000/api/v1/search?q=https://example.com/page1?tag=Favourites" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
            curl "http://localhost:5000/api/v1/search?q=https://example.com?partial=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
        @apiName Search
 | 
			
		||||
        @apiGroup Watch Management
 | 
			
		||||
        @apiQuery {String} q Search query to match against watch URLs and titles
 | 
			
		||||
        @apiQuery {String} [tag] Optional name of tag to limit results (name not UUID)
 | 
			
		||||
        @apiQuery {String} [partial] Allow partial matching of URL query
 | 
			
		||||
        @apiSuccess (200) {Object} JSON Object containing matched watches
 | 
			
		||||
        """
 | 
			
		||||
        query = request.args.get('q', '').strip()
 | 
			
		||||
        tag_limit = request.args.get('tag', '').strip()
 | 
			
		||||
        from changedetectionio.strtobool import strtobool
 | 
			
		||||
        partial = bool(strtobool(request.args.get('partial', '0'))) if 'partial' in request.args else False
 | 
			
		||||
 | 
			
		||||
        # Require a search query
 | 
			
		||||
        if not query:
 | 
			
		||||
            abort(400, message="Search query 'q' parameter is required")
 | 
			
		||||
 | 
			
		||||
        # Use the search function from the datastore
 | 
			
		||||
        matching_uuids = self.datastore.search_watches_for_url(query=query, tag_limit=tag_limit, partial=partial)
 | 
			
		||||
 | 
			
		||||
        # Build the response with watch details
 | 
			
		||||
        results = {}
 | 
			
		||||
        for uuid in matching_uuids:
 | 
			
		||||
            watch = self.datastore.data['watching'].get(uuid)
 | 
			
		||||
            results[uuid] = {
 | 
			
		||||
                'last_changed': watch.last_changed,
 | 
			
		||||
                'last_checked': watch['last_checked'],
 | 
			
		||||
                'last_error': watch['last_error'],
 | 
			
		||||
                'title': watch['title'],
 | 
			
		||||
                'url': watch['url'],
 | 
			
		||||
                'viewed': watch.viewed
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        return results, 200
 | 
			
		||||
							
								
								
									
										54
									
								
								changedetectionio/api/SystemInfo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								changedetectionio/api/SystemInfo.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
			
		||||
from flask_restful import Resource
 | 
			
		||||
from . import auth
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SystemInfo(Resource):
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
        # datastore is a black box dependency
 | 
			
		||||
        self.datastore = kwargs['datastore']
 | 
			
		||||
        self.update_q = kwargs['update_q']
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    def get(self):
 | 
			
		||||
        """
 | 
			
		||||
        @api {get} /api/v1/systeminfo Return system info
 | 
			
		||||
        @apiDescription Return some info about the current system state
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
            HTTP/1.0 200
 | 
			
		||||
            {
 | 
			
		||||
                'queue_size': 10 ,
 | 
			
		||||
                'overdue_watches': ["watch-uuid-list"],
 | 
			
		||||
                'uptime': 38344.55,
 | 
			
		||||
                'watch_count': 800,
 | 
			
		||||
                'version': "0.40.1"
 | 
			
		||||
            }
 | 
			
		||||
        @apiName Get Info
 | 
			
		||||
        @apiGroup System Information
 | 
			
		||||
        """
 | 
			
		||||
        import time
 | 
			
		||||
        overdue_watches = []
 | 
			
		||||
 | 
			
		||||
        # Check all watches and report which have not been checked but should have been
 | 
			
		||||
 | 
			
		||||
        for uuid, watch in self.datastore.data.get('watching', {}).items():
 | 
			
		||||
            # see if now - last_checked is greater than the time that should have been
 | 
			
		||||
            # this is not super accurate (maybe they just edited it) but better than nothing
 | 
			
		||||
            t = watch.threshold_seconds()
 | 
			
		||||
            if not t:
 | 
			
		||||
                # Use the system wide default
 | 
			
		||||
                t = self.datastore.threshold_seconds
 | 
			
		||||
 | 
			
		||||
            time_since_check = time.time() - watch.get('last_checked')
 | 
			
		||||
 | 
			
		||||
            # Allow 5 minutes of grace time before we decide it's overdue
 | 
			
		||||
            if time_since_check - (5 * 60) > t:
 | 
			
		||||
                overdue_watches.append(uuid)
 | 
			
		||||
        from changedetectionio import __version__ as main_version
 | 
			
		||||
        return {
 | 
			
		||||
                   'queue_size': self.update_q.qsize(),
 | 
			
		||||
                   'overdue_watches': overdue_watches,
 | 
			
		||||
                   'uptime': round(time.time() - self.datastore.start_time, 2),
 | 
			
		||||
                   'watch_count': len(self.datastore.data.get('watching', {})),
 | 
			
		||||
                   'version': main_version
 | 
			
		||||
               }, 200
 | 
			
		||||
							
								
								
									
										156
									
								
								changedetectionio/api/Tags.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								changedetectionio/api/Tags.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,156 @@
 | 
			
		||||
from flask_expects_json import expects_json
 | 
			
		||||
from flask_restful import abort, Resource
 | 
			
		||||
from flask import request
 | 
			
		||||
from . import auth
 | 
			
		||||
 | 
			
		||||
# Import schemas from __init__.py
 | 
			
		||||
from . import schema_tag, schema_create_tag, schema_update_tag
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Tag(Resource):
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
        # datastore is a black box dependency
 | 
			
		||||
        self.datastore = kwargs['datastore']
 | 
			
		||||
 | 
			
		||||
    # Get information about a single tag
 | 
			
		||||
    # curl http://localhost:5000/api/v1/tag/<string:uuid>
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    def get(self, uuid):
 | 
			
		||||
        """
 | 
			
		||||
        @api {get} /api/v1/tag/:uuid Single tag - get data or toggle notification muting.
 | 
			
		||||
        @apiDescription Retrieve tag information and set notification_muted status
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
            curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=muted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
        @apiName Tag
 | 
			
		||||
        @apiGroup Tag
 | 
			
		||||
        @apiParam {uuid} uuid Tag unique ID.
 | 
			
		||||
        @apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
 | 
			
		||||
        @apiSuccess (200) {String} OK When muted operation OR full JSON object of the tag
 | 
			
		||||
        @apiSuccess (200) {JSON} TagJSON JSON Full JSON object of the tag
 | 
			
		||||
        """
 | 
			
		||||
        from copy import deepcopy
 | 
			
		||||
        tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid))
 | 
			
		||||
        if not tag:
 | 
			
		||||
            abort(404, message=f'No tag exists with the UUID of {uuid}')
 | 
			
		||||
 | 
			
		||||
        if request.args.get('muted', '') == 'muted':
 | 
			
		||||
            self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True
 | 
			
		||||
            return "OK", 200
 | 
			
		||||
        elif request.args.get('muted', '') == 'unmuted':
 | 
			
		||||
            self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = False
 | 
			
		||||
            return "OK", 200
 | 
			
		||||
 | 
			
		||||
        return tag
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    def delete(self, uuid):
 | 
			
		||||
        """
 | 
			
		||||
        @api {delete} /api/v1/tag/:uuid Delete a tag and remove it from all watches
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
        @apiParam {uuid} uuid Tag unique ID.
 | 
			
		||||
        @apiName DeleteTag
 | 
			
		||||
        @apiGroup Tag
 | 
			
		||||
        @apiSuccess (200) {String} OK Was deleted
 | 
			
		||||
        """
 | 
			
		||||
        if not self.datastore.data['settings']['application']['tags'].get(uuid):
 | 
			
		||||
            abort(400, message='No tag exists with the UUID of {}'.format(uuid))
 | 
			
		||||
 | 
			
		||||
        # Delete the tag, and any tag reference
 | 
			
		||||
        del self.datastore.data['settings']['application']['tags'][uuid]
 | 
			
		||||
        
 | 
			
		||||
        # Remove tag from all watches
 | 
			
		||||
        for watch_uuid, watch in self.datastore.data['watching'].items():
 | 
			
		||||
            if watch.get('tags') and uuid in watch['tags']:
 | 
			
		||||
                watch['tags'].remove(uuid)
 | 
			
		||||
 | 
			
		||||
        return 'OK', 204
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    @expects_json(schema_update_tag)
 | 
			
		||||
    def put(self, uuid):
 | 
			
		||||
        """
 | 
			
		||||
        @api {put} /api/v1/tag/:uuid Update tag information
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            Update (PUT)
 | 
			
		||||
            curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag Title"}'
 | 
			
		||||
 | 
			
		||||
        @apiDescription Updates an existing tag using JSON
 | 
			
		||||
        @apiParam {uuid} uuid Tag unique ID.
 | 
			
		||||
        @apiName UpdateTag
 | 
			
		||||
        @apiGroup Tag
 | 
			
		||||
        @apiSuccess (200) {String} OK Was updated
 | 
			
		||||
        @apiSuccess (500) {String} ERR Some other error
 | 
			
		||||
        """
 | 
			
		||||
        tag = self.datastore.data['settings']['application']['tags'].get(uuid)
 | 
			
		||||
        if not tag:
 | 
			
		||||
            abort(404, message='No tag exists with the UUID of {}'.format(uuid))
 | 
			
		||||
 | 
			
		||||
        tag.update(request.json)
 | 
			
		||||
        self.datastore.needs_write_urgent = True
 | 
			
		||||
 | 
			
		||||
        return "OK", 200
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    # Only cares for {'title': 'xxxx'}
 | 
			
		||||
    def post(self):
 | 
			
		||||
        """
 | 
			
		||||
        @api {post} /api/v1/watch Create a single tag
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"name": "Work related"}'
 | 
			
		||||
        @apiName Create
 | 
			
		||||
        @apiGroup Tag
 | 
			
		||||
        @apiSuccess (200) {String} OK Was created
 | 
			
		||||
        @apiSuccess (500) {String} ERR Some other error
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        json_data = request.get_json()
 | 
			
		||||
        title = json_data.get("title",'').strip()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        new_uuid = self.datastore.add_tag(title=title)
 | 
			
		||||
        if new_uuid:
 | 
			
		||||
            return {'uuid': new_uuid}, 201
 | 
			
		||||
        else:
 | 
			
		||||
            return "Invalid or unsupported tag", 400
 | 
			
		||||
 | 
			
		||||
class Tags(Resource):
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
        # datastore is a black box dependency
 | 
			
		||||
        self.datastore = kwargs['datastore']
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    def get(self):
 | 
			
		||||
        """
 | 
			
		||||
        @api {get} /api/v1/tags List tags
 | 
			
		||||
        @apiDescription Return list of available tags
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl http://localhost:5000/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
            {
 | 
			
		||||
                "cc0cfffa-f449-477b-83ea-0caafd1dc091": {
 | 
			
		||||
                    "title": "Tech News",
 | 
			
		||||
                    "notification_muted": false,
 | 
			
		||||
                    "date_created": 1677103794
 | 
			
		||||
                },
 | 
			
		||||
                "e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
 | 
			
		||||
                    "title": "Shopping",
 | 
			
		||||
                    "notification_muted": true,
 | 
			
		||||
                    "date_created": 1676662819
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        @apiName ListTags
 | 
			
		||||
        @apiGroup Tag Management
 | 
			
		||||
        @apiSuccess (200) {String} OK JSON dict
 | 
			
		||||
        """
 | 
			
		||||
        result = {}
 | 
			
		||||
        for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
 | 
			
		||||
            result[uuid] = {
 | 
			
		||||
                'date_created': tag.get('date_created', 0),
 | 
			
		||||
                'notification_muted': tag.get('notification_muted', False),
 | 
			
		||||
                'title': tag.get('title', ''),
 | 
			
		||||
                'uuid': tag.get('uuid')
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        return result, 200
 | 
			
		||||
@@ -9,20 +9,9 @@ import validators
 | 
			
		||||
from . import auth
 | 
			
		||||
import copy
 | 
			
		||||
 | 
			
		||||
# See docs/README.md for rebuilding the docs/apidoc information
 | 
			
		||||
# Import schemas from __init__.py
 | 
			
		||||
from . import schema, schema_create_watch, schema_update_watch
 | 
			
		||||
 | 
			
		||||
from . import api_schema
 | 
			
		||||
from ..model import watch_base
 | 
			
		||||
 | 
			
		||||
# Build a JSON Schema atleast partially based on our Watch model
 | 
			
		||||
watch_base_config = watch_base()
 | 
			
		||||
schema = api_schema.build_watch_json_schema(watch_base_config)
 | 
			
		||||
 | 
			
		||||
schema_create_watch = copy.deepcopy(schema)
 | 
			
		||||
schema_create_watch['required'] = ['url']
 | 
			
		||||
 | 
			
		||||
schema_update_watch = copy.deepcopy(schema)
 | 
			
		||||
schema_update_watch['additionalProperties'] = False
 | 
			
		||||
 | 
			
		||||
class Watch(Resource):
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
@@ -285,8 +274,6 @@ class CreateWatch(Resource):
 | 
			
		||||
        list = {}
 | 
			
		||||
 | 
			
		||||
        tag_limit = request.args.get('tag', '').lower()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        for uuid, watch in self.datastore.data['watching'].items():
 | 
			
		||||
            # Watch tags by name (replace the other calls?)
 | 
			
		||||
            tags = self.datastore.get_all_tags_for_watch(uuid=uuid)
 | 
			
		||||
@@ -307,110 +294,4 @@ class CreateWatch(Resource):
 | 
			
		||||
                self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
            return {'status': "OK"}, 200
 | 
			
		||||
 | 
			
		||||
        return list, 200
 | 
			
		||||
 | 
			
		||||
class Import(Resource):
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
        # datastore is a black box dependency
 | 
			
		||||
        self.datastore = kwargs['datastore']
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    def post(self):
 | 
			
		||||
        """
 | 
			
		||||
        @api {post} /api/v1/import Import a list of watched URLs
 | 
			
		||||
        @apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag  id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line.
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a"
 | 
			
		||||
        @apiName Import
 | 
			
		||||
        @apiGroup Watch
 | 
			
		||||
        @apiSuccess (200) {List} OK List of watch UUIDs added
 | 
			
		||||
        @apiSuccess (500) {String} ERR Some other error
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        extras = {}
 | 
			
		||||
 | 
			
		||||
        if request.args.get('proxy'):
 | 
			
		||||
            plist = self.datastore.proxy_list
 | 
			
		||||
            if not request.args.get('proxy') in plist:
 | 
			
		||||
                return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400
 | 
			
		||||
            else:
 | 
			
		||||
                extras['proxy'] = request.args.get('proxy')
 | 
			
		||||
 | 
			
		||||
        dedupe = strtobool(request.args.get('dedupe', 'true'))
 | 
			
		||||
 | 
			
		||||
        tags = request.args.get('tag')
 | 
			
		||||
        tag_uuids = request.args.get('tag_uuids')
 | 
			
		||||
 | 
			
		||||
        if tag_uuids:
 | 
			
		||||
            tag_uuids = tag_uuids.split(',')
 | 
			
		||||
 | 
			
		||||
        urls = request.get_data().decode('utf8').splitlines()
 | 
			
		||||
        added = []
 | 
			
		||||
        allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
 | 
			
		||||
        for url in urls:
 | 
			
		||||
            url = url.strip()
 | 
			
		||||
            if not len(url):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            # If hosts that only contain alphanumerics are allowed ("localhost" for example)
 | 
			
		||||
            if not validators.url(url, simple_host=allow_simplehost):
 | 
			
		||||
                return f"Invalid or unsupported URL - {url}", 400
 | 
			
		||||
 | 
			
		||||
            if dedupe and self.datastore.url_exists(url):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids)
 | 
			
		||||
            added.append(new_uuid)
 | 
			
		||||
 | 
			
		||||
        return added
 | 
			
		||||
 | 
			
		||||
class SystemInfo(Resource):
 | 
			
		||||
    def __init__(self, **kwargs):
 | 
			
		||||
        # datastore is a black box dependency
 | 
			
		||||
        self.datastore = kwargs['datastore']
 | 
			
		||||
        self.update_q = kwargs['update_q']
 | 
			
		||||
 | 
			
		||||
    @auth.check_token
 | 
			
		||||
    def get(self):
 | 
			
		||||
        """
 | 
			
		||||
        @api {get} /api/v1/systeminfo Return system info
 | 
			
		||||
        @apiDescription Return some info about the current system state
 | 
			
		||||
        @apiExample {curl} Example usage:
 | 
			
		||||
            curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45"
 | 
			
		||||
            HTTP/1.0 200
 | 
			
		||||
            {
 | 
			
		||||
                'queue_size': 10 ,
 | 
			
		||||
                'overdue_watches': ["watch-uuid-list"],
 | 
			
		||||
                'uptime': 38344.55,
 | 
			
		||||
                'watch_count': 800,
 | 
			
		||||
                'version': "0.40.1"
 | 
			
		||||
            }
 | 
			
		||||
        @apiName Get Info
 | 
			
		||||
        @apiGroup System Information
 | 
			
		||||
        """
 | 
			
		||||
        import time
 | 
			
		||||
        overdue_watches = []
 | 
			
		||||
 | 
			
		||||
        # Check all watches and report which have not been checked but should have been
 | 
			
		||||
 | 
			
		||||
        for uuid, watch in self.datastore.data.get('watching', {}).items():
 | 
			
		||||
            # see if now - last_checked is greater than the time that should have been
 | 
			
		||||
            # this is not super accurate (maybe they just edited it) but better than nothing
 | 
			
		||||
            t = watch.threshold_seconds()
 | 
			
		||||
            if not t:
 | 
			
		||||
                # Use the system wide default
 | 
			
		||||
                t = self.datastore.threshold_seconds
 | 
			
		||||
 | 
			
		||||
            time_since_check = time.time() - watch.get('last_checked')
 | 
			
		||||
 | 
			
		||||
            # Allow 5 minutes of grace time before we decide it's overdue
 | 
			
		||||
            if time_since_check - (5 * 60) > t:
 | 
			
		||||
                overdue_watches.append(uuid)
 | 
			
		||||
        from changedetectionio import __version__ as main_version
 | 
			
		||||
        return {
 | 
			
		||||
                   'queue_size': self.update_q.qsize(),
 | 
			
		||||
                   'overdue_watches': overdue_watches,
 | 
			
		||||
                   'uptime': round(time.time() - self.datastore.start_time, 2),
 | 
			
		||||
                   'watch_count': len(self.datastore.data.get('watching', {})),
 | 
			
		||||
                   'version': main_version
 | 
			
		||||
               }, 200
 | 
			
		||||
        return list, 200
 | 
			
		||||
@@ -0,0 +1,26 @@
 | 
			
		||||
import copy
 | 
			
		||||
from . import api_schema
 | 
			
		||||
from ..model import watch_base
 | 
			
		||||
 | 
			
		||||
# Build a JSON Schema atleast partially based on our Watch model
 | 
			
		||||
watch_base_config = watch_base()
 | 
			
		||||
schema = api_schema.build_watch_json_schema(watch_base_config)
 | 
			
		||||
 | 
			
		||||
schema_create_watch = copy.deepcopy(schema)
 | 
			
		||||
schema_create_watch['required'] = ['url']
 | 
			
		||||
 | 
			
		||||
schema_update_watch = copy.deepcopy(schema)
 | 
			
		||||
schema_update_watch['additionalProperties'] = False
 | 
			
		||||
 | 
			
		||||
# Tag schema is also based on watch_base since Tag inherits from it
 | 
			
		||||
schema_tag = copy.deepcopy(schema)
 | 
			
		||||
schema_create_tag = copy.deepcopy(schema_tag)
 | 
			
		||||
schema_create_tag['required'] = ['title']
 | 
			
		||||
schema_update_tag = copy.deepcopy(schema_tag)
 | 
			
		||||
schema_update_tag['additionalProperties'] = False
 | 
			
		||||
 | 
			
		||||
# Import all API resources
 | 
			
		||||
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch
 | 
			
		||||
from .Tags import Tags, Tag
 | 
			
		||||
from .Import import Import
 | 
			
		||||
from .SystemInfo import SystemInfo
 | 
			
		||||
 
 | 
			
		||||
@@ -11,22 +11,14 @@ def check_token(f):
 | 
			
		||||
        datastore = args[0].datastore
 | 
			
		||||
 | 
			
		||||
        config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled')
 | 
			
		||||
        if not config_api_token_enabled:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            api_key_header = request.headers['x-api-key']
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            return make_response(
 | 
			
		||||
                jsonify("No authorization x-api-key header."), 403
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        config_api_token = datastore.data['settings']['application'].get('api_access_token')
 | 
			
		||||
 | 
			
		||||
        if api_key_header != config_api_token:
 | 
			
		||||
            return make_response(
 | 
			
		||||
                jsonify("Invalid access - API key invalid."), 403
 | 
			
		||||
            )
 | 
			
		||||
        # config_api_token_enabled - a UI option in settings if access should obey the key or not
 | 
			
		||||
        if config_api_token_enabled:
 | 
			
		||||
            if request.headers.get('x-api-key') != config_api_token:
 | 
			
		||||
                return make_response(
 | 
			
		||||
                    jsonify("Invalid access - API key invalid."), 403
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        return f(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +0,0 @@
 | 
			
		||||
from changedetectionio import apprise_plugin
 | 
			
		||||
import apprise
 | 
			
		||||
 | 
			
		||||
# Create our AppriseAsset and populate it with some of our new values:
 | 
			
		||||
# https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object
 | 
			
		||||
asset = apprise.AppriseAsset(
 | 
			
		||||
   image_url_logo='https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png'
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
asset.app_id = "changedetection.io"
 | 
			
		||||
asset.app_desc = "ChangeDetection.io best and simplest website monitoring and change detection"
 | 
			
		||||
asset.app_url = "https://changedetection.io"
 | 
			
		||||
@@ -1,98 +0,0 @@
 | 
			
		||||
# include the decorator
 | 
			
		||||
from apprise.decorators import notify
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from requests.structures import CaseInsensitiveDict
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@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
 | 
			
		||||
    import re
 | 
			
		||||
 | 
			
		||||
    from urllib.parse import unquote_plus
 | 
			
		||||
    from apprise.utils.parse import parse_url as apprise_parse_url
 | 
			
		||||
 | 
			
		||||
    url = kwargs['meta'].get('url')
 | 
			
		||||
    schema = kwargs['meta'].get('schema').lower().strip()
 | 
			
		||||
 | 
			
		||||
    # Choose POST, GET etc from requests
 | 
			
		||||
    method =  re.sub(rf's$', '', schema)
 | 
			
		||||
    requests_method = getattr(requests, method)
 | 
			
		||||
 | 
			
		||||
    params = CaseInsensitiveDict({}) # Added to requests
 | 
			
		||||
    auth = None
 | 
			
		||||
    has_error = False
 | 
			
		||||
 | 
			
		||||
    # Convert /foobar?+some-header=hello to proper header dictionary
 | 
			
		||||
    results = apprise_parse_url(url)
 | 
			
		||||
 | 
			
		||||
    # 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 = CaseInsensitiveDict({unquote_plus(x): unquote_plus(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[unquote_plus(k)] = unquote_plus(v)
 | 
			
		||||
 | 
			
		||||
    # Determine Authentication
 | 
			
		||||
    auth = ''
 | 
			
		||||
    if results.get('user') and results.get('password'):
 | 
			
		||||
        auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user')))
 | 
			
		||||
    elif results.get('user'):
 | 
			
		||||
        auth = (unquote_plus(results.get('user')))
 | 
			
		||||
 | 
			
		||||
    # If it smells like it could be JSON and no content-type was already set, offer a default content type.
 | 
			
		||||
    if body and '{' in body[:100] and not headers.get('Content-Type'):
 | 
			
		||||
        json_header = 'application/json; charset=utf-8'
 | 
			
		||||
        try:
 | 
			
		||||
            # Try if it's JSON
 | 
			
		||||
            json.loads(body)
 | 
			
		||||
            headers['Content-Type'] = json_header
 | 
			
		||||
        except ValueError as e:
 | 
			
		||||
            logger.warning(f"Could not automatically add '{json_header}' header to the notification because the document failed to parse as JSON: {e}")
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    # POSTS -> HTTPS etc
 | 
			
		||||
    if schema.lower().endswith('s'):
 | 
			
		||||
        url = re.sub(rf'^{schema}', 'https', results.get('url'))
 | 
			
		||||
    else:
 | 
			
		||||
        url = re.sub(rf'^{schema}', 'http', results.get('url'))
 | 
			
		||||
 | 
			
		||||
    status_str = ''
 | 
			
		||||
    try:
 | 
			
		||||
        r = requests_method(url,
 | 
			
		||||
          auth=auth,
 | 
			
		||||
          data=body.encode('utf-8') if type(body) is str else body,
 | 
			
		||||
          headers=headers,
 | 
			
		||||
          params=params
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        if not (200 <= r.status_code < 300):
 | 
			
		||||
            status_str = f"Error sending '{method.upper()}' request to {url} - Status: {r.status_code}: '{r.reason}'"
 | 
			
		||||
            logger.error(status_str)
 | 
			
		||||
            has_error = True
 | 
			
		||||
        else:
 | 
			
		||||
            logger.info(f"Sent '{method.upper()}' request to {url}")
 | 
			
		||||
            has_error = False
 | 
			
		||||
 | 
			
		||||
    except requests.RequestException as e:
 | 
			
		||||
        status_str = f"Error sending '{method.upper()}' request to {url} - {str(e)}"
 | 
			
		||||
        logger.error(status_str)
 | 
			
		||||
        has_error = True
 | 
			
		||||
 | 
			
		||||
    if has_error:
 | 
			
		||||
        raise TypeError(status_str)
 | 
			
		||||
 | 
			
		||||
    return True
 | 
			
		||||
							
								
								
									
										16
									
								
								changedetectionio/apprise_plugin/assets.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								changedetectionio/apprise_plugin/assets.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
from apprise import AppriseAsset
 | 
			
		||||
 | 
			
		||||
# Refer to:
 | 
			
		||||
# https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object
 | 
			
		||||
 | 
			
		||||
APPRISE_APP_ID = "changedetection.io"
 | 
			
		||||
APPRISE_APP_DESC = "ChangeDetection.io best and simplest website monitoring and change detection"
 | 
			
		||||
APPRISE_APP_URL = "https://changedetection.io"
 | 
			
		||||
APPRISE_AVATAR_URL = "https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png"
 | 
			
		||||
 | 
			
		||||
apprise_asset = AppriseAsset(
 | 
			
		||||
    app_id=APPRISE_APP_ID,
 | 
			
		||||
    app_desc=APPRISE_APP_DESC,
 | 
			
		||||
    app_url=APPRISE_APP_URL,
 | 
			
		||||
    image_url_logo=APPRISE_AVATAR_URL,
 | 
			
		||||
)
 | 
			
		||||
							
								
								
									
										112
									
								
								changedetectionio/apprise_plugin/custom_handlers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								changedetectionio/apprise_plugin/custom_handlers.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,112 @@
 | 
			
		||||
import json
 | 
			
		||||
import re
 | 
			
		||||
from urllib.parse import unquote_plus
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
from apprise.decorators import notify
 | 
			
		||||
from apprise.utils.parse import parse_url as apprise_parse_url
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from requests.structures import CaseInsensitiveDict
 | 
			
		||||
 | 
			
		||||
SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def notify_supported_methods(func):
 | 
			
		||||
    for method in SUPPORTED_HTTP_METHODS:
 | 
			
		||||
        func = notify(on=method)(func)
 | 
			
		||||
        # Add support for https, for each supported http method
 | 
			
		||||
        func = notify(on=f"{method}s")(func)
 | 
			
		||||
    return func
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_auth(parsed_url: dict) -> str | tuple[str, str]:
 | 
			
		||||
    user: str | None = parsed_url.get("user")
 | 
			
		||||
    password: str | None = parsed_url.get("password")
 | 
			
		||||
 | 
			
		||||
    if user is not None and password is not None:
 | 
			
		||||
        return (unquote_plus(user), unquote_plus(password))
 | 
			
		||||
 | 
			
		||||
    if user is not None:
 | 
			
		||||
        return unquote_plus(user)
 | 
			
		||||
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_headers(parsed_url: dict, body: str) -> CaseInsensitiveDict:
 | 
			
		||||
    headers = CaseInsensitiveDict(
 | 
			
		||||
        {unquote_plus(k).title(): unquote_plus(v) for k, v in parsed_url["qsd+"].items()}
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # If Content-Type is not specified, guess if the body is a valid JSON
 | 
			
		||||
    if headers.get("Content-Type") is None:
 | 
			
		||||
        try:
 | 
			
		||||
            json.loads(body)
 | 
			
		||||
            headers["Content-Type"] = "application/json; charset=utf-8"
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    return headers
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_params(parsed_url: dict) -> CaseInsensitiveDict:
 | 
			
		||||
    # 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
 | 
			
		||||
    params = CaseInsensitiveDict(
 | 
			
		||||
        {
 | 
			
		||||
            unquote_plus(k): unquote_plus(v)
 | 
			
		||||
            for k, v in parsed_url["qsd"].items()
 | 
			
		||||
            if k.strip("-") not in parsed_url["qsd-"]
 | 
			
		||||
            and k.strip("+") not in parsed_url["qsd+"]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return params
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@notify_supported_methods
 | 
			
		||||
def apprise_http_custom_handler(
 | 
			
		||||
    body: str,
 | 
			
		||||
    title: str,
 | 
			
		||||
    notify_type: str,
 | 
			
		||||
    meta: dict,
 | 
			
		||||
    *args,
 | 
			
		||||
    **kwargs,
 | 
			
		||||
) -> bool:
 | 
			
		||||
    url: str = meta.get("url")
 | 
			
		||||
    schema: str = meta.get("schema")
 | 
			
		||||
    method: str = re.sub(r"s$", "", schema).upper()
 | 
			
		||||
 | 
			
		||||
    # Convert /foobar?+some-header=hello to proper header dictionary
 | 
			
		||||
    parsed_url: dict[str, str | dict | None] | None = apprise_parse_url(url)
 | 
			
		||||
    if parsed_url is None:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    auth = _get_auth(parsed_url=parsed_url)
 | 
			
		||||
    headers = _get_headers(parsed_url=parsed_url, body=body)
 | 
			
		||||
    params = _get_params(parsed_url=parsed_url)
 | 
			
		||||
 | 
			
		||||
    url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url"))
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        response = requests.request(
 | 
			
		||||
            method=method,
 | 
			
		||||
            url=url,
 | 
			
		||||
            auth=auth,
 | 
			
		||||
            headers=headers,
 | 
			
		||||
            params=params,
 | 
			
		||||
            data=body.encode("utf-8") if isinstance(body, str) else body,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        response.raise_for_status()
 | 
			
		||||
 | 
			
		||||
        logger.info(f"Successfully sent custom notification to {url}")
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    except requests.RequestException as e:
 | 
			
		||||
        logger.error(f"Remote host error while sending custom notification to {url}: {e}")
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Unexpected error occurred while sending custom notification to {url}: {e}")
 | 
			
		||||
        return False
 | 
			
		||||
@@ -20,10 +20,7 @@ def login_optionally_required(func):
 | 
			
		||||
        has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
 | 
			
		||||
 | 
			
		||||
        # Permitted
 | 
			
		||||
        if request.endpoint and 'static_content' in request.endpoint and request.view_args and request.view_args.get('group') == 'styles':
 | 
			
		||||
            return func(*args, **kwargs)
 | 
			
		||||
        # Permitted
 | 
			
		||||
        elif request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
 | 
			
		||||
        if request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
 | 
			
		||||
            return func(*args, **kwargs)
 | 
			
		||||
        elif request.method in flask_login.config.EXEMPT_METHODS:
 | 
			
		||||
            return func(*args, **kwargs)
 | 
			
		||||
 
 | 
			
		||||
@@ -138,7 +138,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
        return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
 | 
			
		||||
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    @backups_blueprint.route("/", methods=['GET'])
 | 
			
		||||
    @backups_blueprint.route("", methods=['GET'])
 | 
			
		||||
    def index():
 | 
			
		||||
        backups = find_backups()
 | 
			
		||||
        output = render_template("overview.html",
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,6 @@ from loguru import logger
 | 
			
		||||
browsersteps_sessions = {}
 | 
			
		||||
io_interface_context = None
 | 
			
		||||
import json
 | 
			
		||||
import base64
 | 
			
		||||
import hashlib
 | 
			
		||||
from flask import Response
 | 
			
		||||
 | 
			
		||||
@@ -34,10 +33,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
        from . import nonContext
 | 
			
		||||
        from . import browser_steps
 | 
			
		||||
        import time
 | 
			
		||||
        global browsersteps_sessions
 | 
			
		||||
        global io_interface_context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        # We keep the playwright session open for many minutes
 | 
			
		||||
        keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
 | 
			
		||||
 | 
			
		||||
@@ -104,8 +101,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
        # A new session was requested, return sessionID
 | 
			
		||||
 | 
			
		||||
        import uuid
 | 
			
		||||
        global browsersteps_sessions
 | 
			
		||||
 | 
			
		||||
        browsersteps_session_id = str(uuid.uuid4())
 | 
			
		||||
        watch_uuid = request.args.get('uuid')
 | 
			
		||||
 | 
			
		||||
@@ -149,7 +144,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
    def browsersteps_ui_update():
 | 
			
		||||
        import base64
 | 
			
		||||
        import playwright._impl._errors
 | 
			
		||||
        global browsersteps_sessions
 | 
			
		||||
        from changedetectionio.blueprint.browser_steps import browser_steps
 | 
			
		||||
 | 
			
		||||
        remaining =0
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
                    update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
 | 
			
		||||
                if len(importer_handler.remaining_data) == 0:
 | 
			
		||||
                    return redirect(url_for('index'))
 | 
			
		||||
                    return redirect(url_for('watchlist.index'))
 | 
			
		||||
                else:
 | 
			
		||||
                    remaining_urls = importer_handler.remaining_data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -20,13 +20,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
 | 
			
		||||
        datastore.data['watching'][uuid]['processor'] = 'restock_diff'
 | 
			
		||||
        datastore.data['watching'][uuid].clear_watch()
 | 
			
		||||
        update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
 | 
			
		||||
        return redirect(url_for("index"))
 | 
			
		||||
        return redirect(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
    @login_required
 | 
			
		||||
    @price_data_follower_blueprint.route("/<string:uuid>/reject", methods=['GET'])
 | 
			
		||||
    def reject(uuid):
 | 
			
		||||
        datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT
 | 
			
		||||
        return redirect(url_for("index"))
 | 
			
		||||
        return redirect(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    return price_data_follower_blueprint
 | 
			
		||||
 
 | 
			
		||||
@@ -1,103 +1 @@
 | 
			
		||||
import time
 | 
			
		||||
import datetime
 | 
			
		||||
import pytz
 | 
			
		||||
from flask import Blueprint, make_response, request, url_for
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from feedgen.feed import FeedGenerator
 | 
			
		||||
 | 
			
		||||
from changedetectionio.store import ChangeDetectionStore
 | 
			
		||||
from changedetectionio.safe_jinja import render as jinja_render
 | 
			
		||||
 | 
			
		||||
def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
    rss_blueprint = Blueprint('rss', __name__)
 | 
			
		||||
    
 | 
			
		||||
    # Import the login decorator if needed
 | 
			
		||||
    # from changedetectionio.auth_decorator import login_optionally_required
 | 
			
		||||
 | 
			
		||||
    @rss_blueprint.route("/", methods=['GET'])
 | 
			
		||||
    def feed():
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        # Always requires token set
 | 
			
		||||
        app_rss_token = datastore.data['settings']['application'].get('rss_access_token')
 | 
			
		||||
        rss_url_token = request.args.get('token')
 | 
			
		||||
        if rss_url_token != app_rss_token:
 | 
			
		||||
            return "Access denied, bad token", 403
 | 
			
		||||
 | 
			
		||||
        from changedetectionio import diff
 | 
			
		||||
        limit_tag = request.args.get('tag', '').lower().strip()
 | 
			
		||||
        # Be sure limit_tag is a uuid
 | 
			
		||||
        for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
 | 
			
		||||
            if limit_tag == tag.get('title', '').lower().strip():
 | 
			
		||||
                limit_tag = uuid
 | 
			
		||||
 | 
			
		||||
        # Sort by last_changed and add the uuid which is usually the key..
 | 
			
		||||
        sorted_watches = []
 | 
			
		||||
 | 
			
		||||
        # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away
 | 
			
		||||
        for uuid, watch in datastore.data['watching'].items():
 | 
			
		||||
            # @todo tag notification_muted skip also (improve Watch model)
 | 
			
		||||
            if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'):
 | 
			
		||||
                continue
 | 
			
		||||
            if limit_tag and not limit_tag in watch['tags']:
 | 
			
		||||
                continue
 | 
			
		||||
            watch['uuid'] = uuid
 | 
			
		||||
            sorted_watches.append(watch)
 | 
			
		||||
 | 
			
		||||
        sorted_watches.sort(key=lambda x: x.last_changed, reverse=False)
 | 
			
		||||
 | 
			
		||||
        fg = FeedGenerator()
 | 
			
		||||
        fg.title('changedetection.io')
 | 
			
		||||
        fg.description('Feed description')
 | 
			
		||||
        fg.link(href='https://changedetection.io')
 | 
			
		||||
 | 
			
		||||
        for watch in sorted_watches:
 | 
			
		||||
 | 
			
		||||
            dates = list(watch.history.keys())
 | 
			
		||||
            # Re #521 - Don't bother processing this one if theres less than 2 snapshots, means we never had a change detected.
 | 
			
		||||
            if len(dates) < 2:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if not watch.viewed:
 | 
			
		||||
                # Re #239 - GUID needs to be individual for each event
 | 
			
		||||
                # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228)
 | 
			
		||||
                guid = "{}/{}".format(watch['uuid'], watch.last_changed)
 | 
			
		||||
                fe = fg.add_entry()
 | 
			
		||||
 | 
			
		||||
                # Include a link to the diff page, they will have to login here to see if password protection is enabled.
 | 
			
		||||
                # Description is the page you watch, link takes you to the diff JS UI page
 | 
			
		||||
                # Dict val base_url will get overriden with the env var if it is set.
 | 
			
		||||
                ext_base_url = datastore.data['settings']['application'].get('active_base_url')
 | 
			
		||||
 | 
			
		||||
                # Because we are called via whatever web server, flask should figure out the right path (
 | 
			
		||||
                diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)}
 | 
			
		||||
 | 
			
		||||
                fe.link(link=diff_link)
 | 
			
		||||
 | 
			
		||||
                # @todo watch should be a getter - watch.get('title') (internally if URL else..)
 | 
			
		||||
 | 
			
		||||
                watch_title = watch.get('title') if watch.get('title') else watch.get('url')
 | 
			
		||||
                fe.title(title=watch_title)
 | 
			
		||||
 | 
			
		||||
                html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]),
 | 
			
		||||
                                             newest_version_file_contents=watch.get_history_snapshot(dates[-1]),
 | 
			
		||||
                                             include_equal=False,
 | 
			
		||||
                                             line_feed_sep="<br>")
 | 
			
		||||
 | 
			
		||||
                # @todo Make this configurable and also consider html-colored markup
 | 
			
		||||
                # @todo User could decide if <link> goes to the diff page, or to the watch link
 | 
			
		||||
                rss_template = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_title}}</a></h4>\n<p>{{html_diff}}</p>\n</body></html>\n"
 | 
			
		||||
                content = jinja_render(template_str=rss_template, watch_title=watch_title, html_diff=html_diff, watch_url=watch.link)
 | 
			
		||||
 | 
			
		||||
                fe.content(content=content, type='CDATA')
 | 
			
		||||
 | 
			
		||||
                fe.guid(guid, permalink=False)
 | 
			
		||||
                dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key))
 | 
			
		||||
                dt = dt.replace(tzinfo=pytz.UTC)
 | 
			
		||||
                fe.pubDate(dt)
 | 
			
		||||
 | 
			
		||||
        response = make_response(fg.rss_str())
 | 
			
		||||
        response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8')
 | 
			
		||||
        logger.trace(f"RSS generated in {time.time() - now:.3f}s")
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    return rss_blueprint
 | 
			
		||||
RSS_FORMAT_TYPES = [('plaintext', 'Plain text'), ('html', 'HTML Color')]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										147
									
								
								changedetectionio/blueprint/rss/blueprint.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								changedetectionio/blueprint/rss/blueprint.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,147 @@
 | 
			
		||||
 | 
			
		||||
from changedetectionio.safe_jinja import render as jinja_render
 | 
			
		||||
from changedetectionio.store import ChangeDetectionStore
 | 
			
		||||
from feedgen.feed import FeedGenerator
 | 
			
		||||
from flask import Blueprint, make_response, request, url_for, redirect
 | 
			
		||||
from loguru import logger
 | 
			
		||||
import datetime
 | 
			
		||||
import pytz
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
BAD_CHARS_REGEX=r'[\x00-\x08\x0B\x0C\x0E-\x1F]'
 | 
			
		||||
 | 
			
		||||
# Anything that is not text/UTF-8 should be stripped before it breaks feedgen (such as binary data etc)
 | 
			
		||||
def scan_invalid_chars_in_rss(content):
 | 
			
		||||
    for match in re.finditer(BAD_CHARS_REGEX, content):
 | 
			
		||||
        i = match.start()
 | 
			
		||||
        bad_char = content[i]
 | 
			
		||||
        hex_value = f"0x{ord(bad_char):02x}"
 | 
			
		||||
        # Grab context
 | 
			
		||||
        start = max(0, i - 20)
 | 
			
		||||
        end = min(len(content), i + 21)
 | 
			
		||||
        context = content[start:end].replace('\n', '\\n').replace('\r', '\\r')
 | 
			
		||||
        logger.warning(f"Invalid char {hex_value} at pos {i}: ...{context}...")
 | 
			
		||||
        # First match is enough
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def clean_entry_content(content):
 | 
			
		||||
    cleaned = re.sub(BAD_CHARS_REGEX, '', content)
 | 
			
		||||
    return cleaned
 | 
			
		||||
 | 
			
		||||
def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
    rss_blueprint = Blueprint('rss', __name__)
 | 
			
		||||
 | 
			
		||||
    # Some RSS reader situations ended up with rss/ (forward slash after RSS) due
 | 
			
		||||
    # to some earlier blueprint rerouting work, it should goto feed.
 | 
			
		||||
    @rss_blueprint.route("/", methods=['GET'])
 | 
			
		||||
    def extraslash():
 | 
			
		||||
        return redirect(url_for('rss.feed'))
 | 
			
		||||
 | 
			
		||||
    # Import the login decorator if needed
 | 
			
		||||
    # from changedetectionio.auth_decorator import login_optionally_required
 | 
			
		||||
    @rss_blueprint.route("", methods=['GET'])
 | 
			
		||||
    def feed():
 | 
			
		||||
        now = time.time()
 | 
			
		||||
        # Always requires token set
 | 
			
		||||
        app_rss_token = datastore.data['settings']['application'].get('rss_access_token')
 | 
			
		||||
        rss_url_token = request.args.get('token')
 | 
			
		||||
        if rss_url_token != app_rss_token:
 | 
			
		||||
            return "Access denied, bad token", 403
 | 
			
		||||
 | 
			
		||||
        from changedetectionio import diff
 | 
			
		||||
        limit_tag = request.args.get('tag', '').lower().strip()
 | 
			
		||||
        # Be sure limit_tag is a uuid
 | 
			
		||||
        for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
 | 
			
		||||
            if limit_tag == tag.get('title', '').lower().strip():
 | 
			
		||||
                limit_tag = uuid
 | 
			
		||||
 | 
			
		||||
        # Sort by last_changed and add the uuid which is usually the key..
 | 
			
		||||
        sorted_watches = []
 | 
			
		||||
 | 
			
		||||
        # @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away
 | 
			
		||||
        for uuid, watch in datastore.data['watching'].items():
 | 
			
		||||
            # @todo tag notification_muted skip also (improve Watch model)
 | 
			
		||||
            if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'):
 | 
			
		||||
                continue
 | 
			
		||||
            if limit_tag and not limit_tag in watch['tags']:
 | 
			
		||||
                continue
 | 
			
		||||
            watch['uuid'] = uuid
 | 
			
		||||
            sorted_watches.append(watch)
 | 
			
		||||
 | 
			
		||||
        sorted_watches.sort(key=lambda x: x.last_changed, reverse=False)
 | 
			
		||||
 | 
			
		||||
        fg = FeedGenerator()
 | 
			
		||||
        fg.title('changedetection.io')
 | 
			
		||||
        fg.description('Feed description')
 | 
			
		||||
        fg.link(href='https://changedetection.io')
 | 
			
		||||
 | 
			
		||||
        html_colour_enable = False
 | 
			
		||||
        if datastore.data['settings']['application'].get('rss_content_format') == 'html':
 | 
			
		||||
            html_colour_enable = True
 | 
			
		||||
 | 
			
		||||
        for watch in sorted_watches:
 | 
			
		||||
 | 
			
		||||
            dates = list(watch.history.keys())
 | 
			
		||||
            # Re #521 - Don't bother processing this one if theres less than 2 snapshots, means we never had a change detected.
 | 
			
		||||
            if len(dates) < 2:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if not watch.viewed:
 | 
			
		||||
                # Re #239 - GUID needs to be individual for each event
 | 
			
		||||
                # @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228)
 | 
			
		||||
                guid = "{}/{}".format(watch['uuid'], watch.last_changed)
 | 
			
		||||
                fe = fg.add_entry()
 | 
			
		||||
 | 
			
		||||
                # Include a link to the diff page, they will have to login here to see if password protection is enabled.
 | 
			
		||||
                # Description is the page you watch, link takes you to the diff JS UI page
 | 
			
		||||
                # Dict val base_url will get overriden with the env var if it is set.
 | 
			
		||||
                ext_base_url = datastore.data['settings']['application'].get('active_base_url')
 | 
			
		||||
                # @todo fix
 | 
			
		||||
 | 
			
		||||
                # Because we are called via whatever web server, flask should figure out the right path (
 | 
			
		||||
                diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)}
 | 
			
		||||
 | 
			
		||||
                fe.link(link=diff_link)
 | 
			
		||||
 | 
			
		||||
                # @todo watch should be a getter - watch.get('title') (internally if URL else..)
 | 
			
		||||
 | 
			
		||||
                watch_title = watch.get('title') if watch.get('title') else watch.get('url')
 | 
			
		||||
                fe.title(title=watch_title)
 | 
			
		||||
                try:
 | 
			
		||||
 | 
			
		||||
                    html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]),
 | 
			
		||||
                                                 newest_version_file_contents=watch.get_history_snapshot(dates[-1]),
 | 
			
		||||
                                                 include_equal=False,
 | 
			
		||||
                                                 line_feed_sep="<br>",
 | 
			
		||||
                                                 html_colour=html_colour_enable
 | 
			
		||||
                                                 )
 | 
			
		||||
                except FileNotFoundError as e:
 | 
			
		||||
                    html_diff = f"History snapshot file for watch {watch.get('uuid')}@{watch.last_changed} - '{watch.get('title')} not found."
 | 
			
		||||
 | 
			
		||||
                # @todo Make this configurable and also consider html-colored markup
 | 
			
		||||
                # @todo User could decide if <link> goes to the diff page, or to the watch link
 | 
			
		||||
                rss_template = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_title}}</a></h4>\n<p>{{html_diff}}</p>\n</body></html>\n"
 | 
			
		||||
 | 
			
		||||
                content = jinja_render(template_str=rss_template, watch_title=watch_title, html_diff=html_diff, watch_url=watch.link)
 | 
			
		||||
 | 
			
		||||
                # Out of range chars could also break feedgen
 | 
			
		||||
                if scan_invalid_chars_in_rss(content):
 | 
			
		||||
                    content = clean_entry_content(content)
 | 
			
		||||
 | 
			
		||||
                fe.content(content=content, type='CDATA')
 | 
			
		||||
                fe.guid(guid, permalink=False)
 | 
			
		||||
                dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key))
 | 
			
		||||
                dt = dt.replace(tzinfo=pytz.UTC)
 | 
			
		||||
                fe.pubDate(dt)
 | 
			
		||||
 | 
			
		||||
        response = make_response(fg.rss_str())
 | 
			
		||||
        response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8')
 | 
			
		||||
        logger.trace(f"RSS generated in {time.time() - now:.3f}s")
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    return rss_blueprint
 | 
			
		||||
@@ -13,7 +13,7 @@ from changedetectionio.auth_decorator import login_optionally_required
 | 
			
		||||
def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
    settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
 | 
			
		||||
 | 
			
		||||
    @settings_blueprint.route("/", methods=['GET', "POST"])
 | 
			
		||||
    @settings_blueprint.route("", methods=['GET', "POST"])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    def settings_page():
 | 
			
		||||
        from changedetectionio import forms
 | 
			
		||||
@@ -74,7 +74,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
                    datastore.needs_write_urgent = True
 | 
			
		||||
                    flash("Password protection enabled.", 'notice')
 | 
			
		||||
                    flask_login.logout_user()
 | 
			
		||||
                    return redirect(url_for('index'))
 | 
			
		||||
                    return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
                datastore.needs_write_urgent = True
 | 
			
		||||
                flash("Settings updated.")
 | 
			
		||||
 
 | 
			
		||||
@@ -78,7 +78,10 @@
 | 
			
		||||
                        {{ render_field(form.application.form.pager_size) }}
 | 
			
		||||
                        <span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
                        {{ render_field(form.application.form.rss_content_format) }}
 | 
			
		||||
                        <span class="pure-form-message-inline">Love RSS? Does your reader support HTML? Set it here</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
                        {{ render_checkbox_field(form.application.form.extract_title_as_title) }}
 | 
			
		||||
                        <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span>
 | 
			
		||||
@@ -299,7 +302,7 @@ nav
 | 
			
		||||
            <div id="actions">
 | 
			
		||||
                <div class="pure-control-group">
 | 
			
		||||
                    {{ render_button(form.save_button) }}
 | 
			
		||||
                    <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a>
 | 
			
		||||
                    <a href="{{url_for('watchlist.index')}}" class="pure-button button-small button-cancel">Back</a>
 | 
			
		||||
                    <a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -47,7 +47,7 @@
 | 
			
		||||
                    <a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a>
 | 
			
		||||
                </td>
 | 
			
		||||
                <td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
 | 
			
		||||
                <td class="title-col inline"> <a href="{{url_for('index', tag=uuid) }}">{{ tag.title }}</a></td>
 | 
			
		||||
                <td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td>
 | 
			
		||||
                <td>
 | 
			
		||||
                    <a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a> 
 | 
			
		||||
                    <a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a>
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
        else:
 | 
			
		||||
            flash("Cleared snapshot history for watch {}".format(uuid))
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
        return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
    @ui_blueprint.route("/clear_history", methods=['GET', 'POST'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
@@ -52,7 +52,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
            else:
 | 
			
		||||
                flash('Incorrect confirmation text.', 'error')
 | 
			
		||||
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        output = render_template("clear_all_history.html")
 | 
			
		||||
        return output
 | 
			
		||||
@@ -68,7 +68,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
                continue
 | 
			
		||||
            datastore.set_last_viewed(watch_uuid, int(time.time()))
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
        return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
    @ui_blueprint.route("/delete", methods=['GET'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
@@ -77,7 +77,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
 | 
			
		||||
        if uuid != 'all' and not uuid in datastore.data['watching'].keys():
 | 
			
		||||
            flash('The watch by UUID {} does not exist.'.format(uuid), 'error')
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        # More for testing, possible to return the first/only
 | 
			
		||||
        if uuid == 'first':
 | 
			
		||||
@@ -85,7 +85,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
        datastore.delete(uuid)
 | 
			
		||||
        flash('Deleted.')
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
        return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
    @ui_blueprint.route("/clone", methods=['GET'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
@@ -96,12 +96,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
            uuid = list(datastore.data['watching'].keys()).pop()
 | 
			
		||||
 | 
			
		||||
        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}))
 | 
			
		||||
            flash('Cloned.')
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
        if not datastore.data['watching'].get(uuid).get('paused'):
 | 
			
		||||
            update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
 | 
			
		||||
 | 
			
		||||
        flash('Cloned, you are editing the new watch.')
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for("ui.ui_edit.edit_page", uuid=new_uuid))
 | 
			
		||||
 | 
			
		||||
    @ui_blueprint.route("/checknow", methods=['GET'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
@@ -143,7 +144,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
        if i == 0:
 | 
			
		||||
            flash("No watches available to recheck.")
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
        return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
    @ui_blueprint.route("/form/checkbox-operations", methods=['POST'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
@@ -231,7 +232,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
        elif (op == 'assign-tag'):
 | 
			
		||||
            op_extradata = request.form.get('op_extradata', '').strip()
 | 
			
		||||
            if op_extradata:
 | 
			
		||||
                tag_uuid = datastore.add_tag(name=op_extradata)
 | 
			
		||||
                tag_uuid = datastore.add_tag(title=op_extradata)
 | 
			
		||||
                if op_extradata and tag_uuid:
 | 
			
		||||
                    for uuid in uuids:
 | 
			
		||||
                        uuid = uuid.strip()
 | 
			
		||||
@@ -244,7 +245,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
 | 
			
		||||
            flash(f"{len(uuids)} watches were tagged")
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
        return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    @ui_blueprint.route("/share-url/<string:uuid>", methods=['GET'])
 | 
			
		||||
@@ -296,6 +297,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
 | 
			
		||||
            logger.error(f"Error sharing -{str(e)}")
 | 
			
		||||
            flash(f"Could not share, something went wrong while communicating with the share server - {str(e)}", 'error')
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
        return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
    return ui_blueprint
 | 
			
		||||
@@ -32,14 +32,14 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
        # More for testing, possible to return the first/only
 | 
			
		||||
        if not datastore.data['watching'].keys():
 | 
			
		||||
            flash("No watches to edit", "error")
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        if uuid == 'first':
 | 
			
		||||
            uuid = list(datastore.data['watching'].keys()).pop()
 | 
			
		||||
 | 
			
		||||
        if not uuid in datastore.data['watching']:
 | 
			
		||||
            flash("No watch with the UUID %s found." % (uuid), "error")
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        switch_processor = request.args.get('switch_processor')
 | 
			
		||||
        if switch_processor:
 | 
			
		||||
@@ -66,7 +66,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
        processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None)
 | 
			
		||||
        if not processor_classes:
 | 
			
		||||
            flash(f"Cannot load the edit form for processor/plugin '{processor_classes[1]}', plugin missing?", 'error')
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        parent_module = processors.get_parent_module(processor_classes[0])
 | 
			
		||||
 | 
			
		||||
@@ -153,7 +153,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
                    extra_update_obj['tags'] = form.data.get('tags')
 | 
			
		||||
                else:
 | 
			
		||||
                    for t in form.data.get('tags').split(','):
 | 
			
		||||
                        tag_uuids.append(datastore.add_tag(name=t))
 | 
			
		||||
                        tag_uuids.append(datastore.add_tag(title=t))
 | 
			
		||||
                    extra_update_obj['tags'] = tag_uuids
 | 
			
		||||
 | 
			
		||||
            datastore.data['watching'][uuid].update(form.data)
 | 
			
		||||
@@ -207,7 +207,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
            if request.args.get("next") and request.args.get("next") == 'diff':
 | 
			
		||||
                return redirect(url_for('ui.ui_views.diff_history_page', uuid=uuid))
 | 
			
		||||
 | 
			
		||||
            return redirect(url_for('index', tag=request.args.get("tag",'')))
 | 
			
		||||
            return redirect(url_for('watchlist.index', tag=request.args.get("tag",'')))
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            if request.method == 'POST' and not form.validate():
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ from loguru import logger
 | 
			
		||||
 | 
			
		||||
from changedetectionio.store import ChangeDetectionStore
 | 
			
		||||
from changedetectionio.auth_decorator import login_optionally_required
 | 
			
		||||
from changedetectionio.notification import process_notification
 | 
			
		||||
 | 
			
		||||
def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
    notification_blueprint = Blueprint('ui_notification', __name__, template_folder="../ui/templates")
 | 
			
		||||
@@ -17,11 +18,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
 | 
			
		||||
        # Watch_uuid could be unset in the case it`s used in tag editor, global settings
 | 
			
		||||
        import apprise
 | 
			
		||||
        from changedetectionio.apprise_asset import asset
 | 
			
		||||
        apobj = apprise.Apprise(asset=asset)
 | 
			
		||||
        from ...apprise_plugin.assets import apprise_asset
 | 
			
		||||
        from ...apprise_plugin.custom_handlers import apprise_http_custom_handler  # noqa: F401
 | 
			
		||||
        apobj = apprise.Apprise(asset=apprise_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'
 | 
			
		||||
 | 
			
		||||
@@ -90,7 +90,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
 | 
			
		||||
 | 
			
		||||
            n_object['as_async'] = False
 | 
			
		||||
            n_object.update(watch.extra_notification_token_values())
 | 
			
		||||
            from changedetectionio.notification import process_notification
 | 
			
		||||
            sent_obj = process_notification(n_object, datastore)
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
 
 | 
			
		||||
@@ -37,7 +37,7 @@
 | 
			
		||||
        </div>
 | 
			
		||||
        <br />
 | 
			
		||||
        <div class="pure-control-group">
 | 
			
		||||
          <a href="{{url_for('index')}}" class="pure-button button-cancel"
 | 
			
		||||
          <a href="{{url_for('watchlist.index')}}" class="pure-button button-cancel"
 | 
			
		||||
            >Cancel</a
 | 
			
		||||
          >
 | 
			
		||||
        </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
            watch = datastore.data['watching'][uuid]
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            flash("No history found for the specified link, bad link?", "error")
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
 | 
			
		||||
        extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
 | 
			
		||||
@@ -91,7 +91,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
            watch = datastore.data['watching'][uuid]
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            flash("No history found for the specified link, bad link?", "error")
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        # For submission of requesting an extract
 | 
			
		||||
        extract_form = forms.extractDataForm(request.form)
 | 
			
		||||
@@ -119,7 +119,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
 | 
			
		||||
        if len(dates) < 2:
 | 
			
		||||
            flash("Not enough saved change detection snapshots to produce a report.", "error")
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        # Save the current newest history as the most recently viewed
 | 
			
		||||
        datastore.set_last_viewed(uuid, time.time())
 | 
			
		||||
@@ -196,7 +196,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
        if not form.validate():
 | 
			
		||||
            for widget, l in form.errors.items():
 | 
			
		||||
                flash(','.join(l), 'error')
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        url = request.form.get('url').strip()
 | 
			
		||||
        if datastore.url_exists(url):
 | 
			
		||||
@@ -215,6 +215,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
 | 
			
		||||
                update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
 | 
			
		||||
                flash("Watch added.")
 | 
			
		||||
 | 
			
		||||
        return redirect(url_for('index', tag=request.args.get('tag','')))
 | 
			
		||||
        return redirect(url_for('watchlist.index', tag=request.args.get('tag','')))
 | 
			
		||||
 | 
			
		||||
    return views_blueprint
 | 
			
		||||
							
								
								
									
										111
									
								
								changedetectionio/blueprint/watchlist/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								changedetectionio/blueprint/watchlist/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,111 @@
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session
 | 
			
		||||
from flask_login import current_user
 | 
			
		||||
from flask_paginate import Pagination, get_page_parameter
 | 
			
		||||
 | 
			
		||||
from changedetectionio import forms
 | 
			
		||||
from changedetectionio.store import ChangeDetectionStore
 | 
			
		||||
from changedetectionio.auth_decorator import login_optionally_required
 | 
			
		||||
 | 
			
		||||
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
 | 
			
		||||
    watchlist_blueprint = Blueprint('watchlist', __name__, template_folder="templates")
 | 
			
		||||
    
 | 
			
		||||
    @watchlist_blueprint.route("/", methods=['GET'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    def index():
 | 
			
		||||
        active_tag_req = request.args.get('tag', '').lower().strip()
 | 
			
		||||
        active_tag_uuid = active_tag = None
 | 
			
		||||
 | 
			
		||||
        # Be sure limit_tag is a uuid
 | 
			
		||||
        if active_tag_req:
 | 
			
		||||
            for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
 | 
			
		||||
                if active_tag_req == tag.get('title', '').lower().strip() or active_tag_req == uuid:
 | 
			
		||||
                    active_tag = tag
 | 
			
		||||
                    active_tag_uuid = uuid
 | 
			
		||||
                    break
 | 
			
		||||
 | 
			
		||||
        # Redirect for the old rss path which used the /?rss=true
 | 
			
		||||
        if request.args.get('rss'):
 | 
			
		||||
            return redirect(url_for('rss.feed', tag=active_tag_uuid))
 | 
			
		||||
 | 
			
		||||
        op = request.args.get('op')
 | 
			
		||||
        if op:
 | 
			
		||||
            uuid = request.args.get('uuid')
 | 
			
		||||
            if op == 'pause':
 | 
			
		||||
                datastore.data['watching'][uuid].toggle_pause()
 | 
			
		||||
            elif op == 'mute':
 | 
			
		||||
                datastore.data['watching'][uuid].toggle_mute()
 | 
			
		||||
 | 
			
		||||
            datastore.needs_write = True
 | 
			
		||||
            return redirect(url_for('watchlist.index', tag = active_tag_uuid))
 | 
			
		||||
 | 
			
		||||
        # Sort by last_changed and add the uuid which is usually the key..
 | 
			
		||||
        sorted_watches = []
 | 
			
		||||
        with_errors = request.args.get('with_errors') == "1"
 | 
			
		||||
        errored_count = 0
 | 
			
		||||
        search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
 | 
			
		||||
        for uuid, watch in datastore.data['watching'].items():
 | 
			
		||||
            if with_errors and not watch.get('last_error'):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if active_tag_uuid and not active_tag_uuid in watch['tags']:
 | 
			
		||||
                    continue
 | 
			
		||||
            if watch.get('last_error'):
 | 
			
		||||
                errored_count += 1
 | 
			
		||||
 | 
			
		||||
            if search_q:
 | 
			
		||||
                if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
 | 
			
		||||
                    sorted_watches.append(watch)
 | 
			
		||||
                elif watch.get('last_error') and search_q in watch.get('last_error').lower():
 | 
			
		||||
                    sorted_watches.append(watch)
 | 
			
		||||
            else:
 | 
			
		||||
                sorted_watches.append(watch)
 | 
			
		||||
 | 
			
		||||
        form = forms.quickWatchForm(request.form)
 | 
			
		||||
        page = request.args.get(get_page_parameter(), type=int, default=1)
 | 
			
		||||
        total_count = len(sorted_watches)
 | 
			
		||||
 | 
			
		||||
        pagination = Pagination(page=page,
 | 
			
		||||
                                total=total_count,
 | 
			
		||||
                                per_page=datastore.data['settings']['application'].get('pager_size', 50), css_framework="semantic")
 | 
			
		||||
 | 
			
		||||
        sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
 | 
			
		||||
        output = render_template(
 | 
			
		||||
            "watch-overview.html",
 | 
			
		||||
                                 active_tag=active_tag,
 | 
			
		||||
                                 active_tag_uuid=active_tag_uuid,
 | 
			
		||||
                                 app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
 | 
			
		||||
                                 datastore=datastore,
 | 
			
		||||
                                 errored_count=errored_count,
 | 
			
		||||
                                 form=form,
 | 
			
		||||
                                 guid=datastore.data['app_guid'],
 | 
			
		||||
                                 has_proxies=datastore.proxy_list,
 | 
			
		||||
                                 has_unviewed=datastore.has_unviewed,
 | 
			
		||||
                                 hosted_sticky=os.getenv("SALTED_PASS", False) == False,
 | 
			
		||||
                                 now_time_server=time.time(),
 | 
			
		||||
                                 pagination=pagination,
 | 
			
		||||
                                 queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
 | 
			
		||||
                                 search_q=request.args.get('q', '').strip(),
 | 
			
		||||
                                 sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
 | 
			
		||||
                                 sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
 | 
			
		||||
                                 system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
 | 
			
		||||
                                 tags=sorted_tags,
 | 
			
		||||
                                 watches=sorted_watches
 | 
			
		||||
                                 )
 | 
			
		||||
 | 
			
		||||
        if session.get('share-link'):
 | 
			
		||||
            del(session['share-link'])
 | 
			
		||||
 | 
			
		||||
        resp = make_response(output)
 | 
			
		||||
 | 
			
		||||
        # The template can run on cookie or url query info
 | 
			
		||||
        if request.args.get('sort'):
 | 
			
		||||
            resp.set_cookie('sort', request.args.get('sort'))
 | 
			
		||||
        if request.args.get('order'):
 | 
			
		||||
            resp.set_cookie('order', request.args.get('order'))
 | 
			
		||||
 | 
			
		||||
        return resp
 | 
			
		||||
        
 | 
			
		||||
    return watchlist_blueprint
 | 
			
		||||
@@ -3,7 +3,16 @@
 | 
			
		||||
{% from '_helpers.html' import render_simple_field, render_field, render_nolabel_field, sort_by_title %}
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
 | 
			
		||||
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
 | 
			
		||||
<script>let nowtimeserver={{ now_time_server }};</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.checking-now .last-checked {
 | 
			
		||||
    background-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.05) 40%, rgba(0,0,0,0.1) 100%);
 | 
			
		||||
    background-size: 0 100%;
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
    transition: background-size 0.9s ease
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
<div class="box">
 | 
			
		||||
 | 
			
		||||
    <form class="pure-form" action="{{ url_for('ui.ui_views.form_quick_watch_add', tag=active_tag_uuid) }}" method="POST" id="new-watch-form">
 | 
			
		||||
@@ -46,12 +55,12 @@
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {% if search_q %}<div id="search-result-info">Searching "<strong><i>{{search_q}}</i></strong>"</div>{% endif %}
 | 
			
		||||
    <div>
 | 
			
		||||
        <a href="{{url_for('index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">All</a>
 | 
			
		||||
        <a href="{{url_for('watchlist.index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">All</a>
 | 
			
		||||
 | 
			
		||||
    <!-- tag list -->
 | 
			
		||||
    {% for uuid, tag in tags %}
 | 
			
		||||
        {% if tag != "" %}
 | 
			
		||||
            <a href="{{url_for('index', tag=uuid) }}" class="pure-button button-tag {{'active' if active_tag_uuid == uuid }}">{{ tag.title }}</a>
 | 
			
		||||
            <a href="{{url_for('watchlist.index', tag=uuid) }}" class="pure-button button-tag {{'active' if active_tag_uuid == uuid }}">{{ tag.title }}</a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -72,14 +81,14 @@
 | 
			
		||||
            <tr>
 | 
			
		||||
                {% set link_order = "desc" if sort_order  == 'asc' else "asc" %}
 | 
			
		||||
                {% set arrow_span = "" %}
 | 
			
		||||
                <th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}"  href="{{url_for('index', sort='date_created', order=link_order, tag=active_tag_uuid)}}"># <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th><input style="vertical-align: middle" type="checkbox" id="check-all" > <a class="{{ 'active '+link_order if sort_attribute == 'date_created' else 'inactive' }}"  href="{{url_for('watchlist.index', sort='date_created', order=link_order, tag=active_tag_uuid)}}"># <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th class="empty-cell"></th>
 | 
			
		||||
                <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('index', sort='label', order=link_order, tag=active_tag_uuid)}}">Website <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th><a class="{{ 'active '+link_order if sort_attribute == 'label' else 'inactive' }}" href="{{url_for('watchlist.index', sort='label', order=link_order, tag=active_tag_uuid)}}">Website <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
             {% 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)}}"><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><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Checked <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('watchlist.index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Changed <span class='arrow {{link_order}}'></span></a></th>
 | 
			
		||||
                <th class="empty-cell"></th>
 | 
			
		||||
            </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
@@ -91,8 +100,8 @@
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) %}
 | 
			
		||||
 | 
			
		||||
                {% set is_unviewed =  watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}
 | 
			
		||||
 | 
			
		||||
                {% set is_unviewed = watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}
 | 
			
		||||
                {% set checking_now = is_checking_now(watch) %}
 | 
			
		||||
            <tr id="{{ watch.uuid }}"
 | 
			
		||||
                class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} processor-{{ watch['processor'] }}
 | 
			
		||||
                {% if watch.last_error is defined and watch.last_error != False %}error{% endif %}
 | 
			
		||||
@@ -100,16 +109,18 @@
 | 
			
		||||
                {% if watch.paused is defined and watch.paused != False %}paused{% endif %}
 | 
			
		||||
                {% if is_unviewed %}unviewed{% endif %}
 | 
			
		||||
                {% if watch.has_restock_info %} has-restock-info {% if watch['restock']['in_stock'] %}in-stock{% else %}not-in-stock{% endif %} {% else %}no-restock-info{% endif %}
 | 
			
		||||
                {% if watch.uuid in queued_uuids %}queued{% endif %}">
 | 
			
		||||
                {% if watch.uuid in queued_uuids %}queued{% endif %}
 | 
			
		||||
                {% if checking_now %}checking-now{% endif %}
 | 
			
		||||
                ">
 | 
			
		||||
                <td class="inline checkbox-uuid" ><input name="uuids"  type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td>
 | 
			
		||||
                <td class="inline watch-controls">
 | 
			
		||||
                    {% if not watch.paused %}
 | 
			
		||||
                    <a class="state-off" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a>
 | 
			
		||||
                    <a class="state-off" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a>
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                    <a class="state-on" href="{{url_for('index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a>
 | 
			
		||||
                    <a class="state-on" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                    {% set mute_label = 'UnMute notification' if watch.notification_muted else 'Mute notification' %}
 | 
			
		||||
                    <a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{{ mute_label }}" title="{{ mute_label }}" class="icon icon-mute" ></a>
 | 
			
		||||
                    <a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{{ mute_label }}" title="{{ mute_label }}" class="icon icon-mute" ></a>
 | 
			
		||||
                </td>
 | 
			
		||||
                <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}
 | 
			
		||||
                    <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a>
 | 
			
		||||
@@ -178,7 +189,14 @@
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                </td>
 | 
			
		||||
{% endif %}
 | 
			
		||||
                <td class="last-checked" data-timestamp="{{ watch.last_checked }}">{{watch|format_last_checked_time|safe}}</td>
 | 
			
		||||
            {#last_checked becomes fetch-start-time#}
 | 
			
		||||
                <td class="last-checked" data-timestamp="{{ watch.last_checked }}" {% if checking_now %} data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" {% endif %} >
 | 
			
		||||
                    {% if checking_now %}
 | 
			
		||||
                        <span class="spinner"></span><span> Checking now</span>
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                        {{watch|format_last_checked_time|safe}}</td>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
 | 
			
		||||
                <td class="last-changed" data-timestamp="{{ watch.last_changed }}">{% if watch.history_n >=2 and watch.last_changed >0 %}
 | 
			
		||||
                    {{watch.last_changed|format_timestamp_timeago}}
 | 
			
		||||
                    {% else %}
 | 
			
		||||
@@ -210,7 +228,7 @@
 | 
			
		||||
        <ul id="post-list-buttons">
 | 
			
		||||
            {% if errored_count %}
 | 
			
		||||
            <li>
 | 
			
		||||
                <a href="{{url_for('index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error ">With errors ({{ errored_count }})</a>
 | 
			
		||||
                <a href="{{url_for('watchlist.index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error ">With errors ({{ errored_count }})</a>
 | 
			
		||||
            </li>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if has_unviewed %}
 | 
			
		||||
@@ -116,8 +116,7 @@ def execute_ruleset_against_all_plugins(current_watch_uuid: str, application_dat
 | 
			
		||||
            if not jsonLogic(logic=ruleset, data=EXECUTE_DATA, operations=CUSTOM_OPERATIONS):
 | 
			
		||||
                result = False
 | 
			
		||||
 | 
			
		||||
    return result
 | 
			
		||||
 | 
			
		||||
    return {'executed_data': EXECUTE_DATA, 'result': result}
 | 
			
		||||
 | 
			
		||||
# Load plugins dynamically
 | 
			
		||||
for plugin in plugin_manager.get_plugins():
 | 
			
		||||
 
 | 
			
		||||
@@ -67,7 +67,8 @@ def construct_blueprint(datastore):
 | 
			
		||||
 | 
			
		||||
            return jsonify({
 | 
			
		||||
                'status': 'success',
 | 
			
		||||
                'result': result,
 | 
			
		||||
                'result': result.get('result'),
 | 
			
		||||
                'data': result.get('executed_data'),
 | 
			
		||||
                'message': 'Condition passes' if result else 'Condition does not pass'
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,8 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
import flask_login
 | 
			
		||||
import locale
 | 
			
		||||
import os
 | 
			
		||||
import pytz
 | 
			
		||||
import queue
 | 
			
		||||
import threading
 | 
			
		||||
import time
 | 
			
		||||
@@ -35,7 +33,8 @@ from loguru import logger
 | 
			
		||||
 | 
			
		||||
from changedetectionio import __version__
 | 
			
		||||
from changedetectionio import queuedWatchMetaData
 | 
			
		||||
from changedetectionio.api import api_v1
 | 
			
		||||
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags
 | 
			
		||||
from changedetectionio.api.Search import Search
 | 
			
		||||
from .time_handler import is_within_schedule
 | 
			
		||||
 | 
			
		||||
datastore = None
 | 
			
		||||
@@ -124,14 +123,18 @@ def _jinja2_filter_format_number_locale(value: float) -> str:
 | 
			
		||||
 | 
			
		||||
    return formatted_value
 | 
			
		||||
 | 
			
		||||
@app.template_global('is_checking_now')
 | 
			
		||||
def _watch_is_checking_now(watch_obj, format="%Y-%m-%d %H:%M:%S"):
 | 
			
		||||
    # Worker thread tells us which UUID it is currently processing.
 | 
			
		||||
    for t in running_update_threads:
 | 
			
		||||
        if t.current_uuid == watch_obj['uuid']:
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# We use the whole watch object from the store/JSON so we can see if there's some related status in terms of a thread
 | 
			
		||||
# running or something similar.
 | 
			
		||||
@app.template_filter('format_last_checked_time')
 | 
			
		||||
def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"):
 | 
			
		||||
    # Worker thread tells us which UUID it is currently processing.
 | 
			
		||||
    for t in running_update_threads:
 | 
			
		||||
        if t.current_uuid == watch_obj['uuid']:
 | 
			
		||||
            return '<span class="spinner"></span><span> Checking now</span>'
 | 
			
		||||
 | 
			
		||||
    if watch_obj['last_checked'] == 0:
 | 
			
		||||
        return 'Not yet'
 | 
			
		||||
@@ -230,7 +233,8 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
        if has_password_enabled and not flask_login.current_user.is_authenticated:
 | 
			
		||||
            # Permitted
 | 
			
		||||
            if request.endpoint and 'static_content' in request.endpoint and request.view_args and request.view_args.get('group') == 'styles':
 | 
			
		||||
            if request.endpoint and request.endpoint == 'static_content' and request.view_args:
 | 
			
		||||
                # Handled by static_content handler
 | 
			
		||||
                return None
 | 
			
		||||
            # Permitted
 | 
			
		||||
            elif request.endpoint and 'login' in request.endpoint:
 | 
			
		||||
@@ -244,34 +248,42 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            # RSS access with token is allowed
 | 
			
		||||
            elif request.endpoint and 'rss.feed' in request.endpoint:
 | 
			
		||||
                return None
 | 
			
		||||
            # API routes - use their own auth mechanism (@auth.check_token)
 | 
			
		||||
            elif request.path.startswith('/api/'):
 | 
			
		||||
                return None
 | 
			
		||||
            else:
 | 
			
		||||
                return login_manager.unauthorized()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    watch_api.add_resource(api_v1.WatchSingleHistory,
 | 
			
		||||
    watch_api.add_resource(WatchSingleHistory,
 | 
			
		||||
                           '/api/v1/watch/<string:uuid>/history/<string:timestamp>',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
 | 
			
		||||
 | 
			
		||||
    watch_api.add_resource(api_v1.WatchHistory,
 | 
			
		||||
    watch_api.add_resource(WatchHistory,
 | 
			
		||||
                           '/api/v1/watch/<string:uuid>/history',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore})
 | 
			
		||||
 | 
			
		||||
    watch_api.add_resource(api_v1.CreateWatch, '/api/v1/watch',
 | 
			
		||||
    watch_api.add_resource(CreateWatch, '/api/v1/watch',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
 | 
			
		||||
 | 
			
		||||
    watch_api.add_resource(api_v1.Watch, '/api/v1/watch/<string:uuid>',
 | 
			
		||||
    watch_api.add_resource(Watch, '/api/v1/watch/<string:uuid>',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
 | 
			
		||||
 | 
			
		||||
    watch_api.add_resource(api_v1.SystemInfo, '/api/v1/systeminfo',
 | 
			
		||||
    watch_api.add_resource(SystemInfo, '/api/v1/systeminfo',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
 | 
			
		||||
 | 
			
		||||
    watch_api.add_resource(api_v1.Import,
 | 
			
		||||
    watch_api.add_resource(Import,
 | 
			
		||||
                           '/api/v1/import',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore})
 | 
			
		||||
 | 
			
		||||
    # Setup cors headers to allow all domains
 | 
			
		||||
    # https://flask-cors.readthedocs.io/en/latest/
 | 
			
		||||
    #    CORS(app)
 | 
			
		||||
    watch_api.add_resource(Tags, '/api/v1/tags',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore})
 | 
			
		||||
 | 
			
		||||
    watch_api.add_resource(Tag, '/api/v1/tag', '/api/v1/tag/<string:uuid>',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore})
 | 
			
		||||
                           
 | 
			
		||||
    watch_api.add_resource(Search, '/api/v1/search',
 | 
			
		||||
                           resource_class_kwargs={'datastore': datastore})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -284,12 +296,12 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
    @login_manager.unauthorized_handler
 | 
			
		||||
    def unauthorized_handler():
 | 
			
		||||
        flash("You must be logged in, please log in.", 'error')
 | 
			
		||||
        return redirect(url_for('login', next=url_for('index')))
 | 
			
		||||
        return redirect(url_for('login', next=url_for('watchlist.index')))
 | 
			
		||||
 | 
			
		||||
    @app.route('/logout')
 | 
			
		||||
    def logout():
 | 
			
		||||
        flask_login.logout_user()
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
        return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
    # https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39
 | 
			
		||||
    # You can divide up the stuff like this
 | 
			
		||||
@@ -299,7 +311,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        if request.method == 'GET':
 | 
			
		||||
            if flask_login.current_user.is_authenticated:
 | 
			
		||||
                flash("Already logged in")
 | 
			
		||||
                return redirect(url_for("index"))
 | 
			
		||||
                return redirect(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
            output = render_template("login.html")
 | 
			
		||||
            return output
 | 
			
		||||
@@ -316,13 +328,13 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            # It's more reliable and safe to ignore the 'next' redirect
 | 
			
		||||
            # When we used...
 | 
			
		||||
            # next = request.args.get('next')
 | 
			
		||||
            # return redirect(next or url_for('index'))
 | 
			
		||||
            # return redirect(next or url_for('watchlist.index'))
 | 
			
		||||
            # We would sometimes get login loop errors on sites hosted in sub-paths
 | 
			
		||||
 | 
			
		||||
            # note for the future:
 | 
			
		||||
            #            if not is_safe_url(next):
 | 
			
		||||
            #                return flask.abort(400)
 | 
			
		||||
            return redirect(url_for('index'))
 | 
			
		||||
            return redirect(url_for('watchlist.index'))
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            flash('Incorrect password', 'error')
 | 
			
		||||
@@ -335,118 +347,20 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:
 | 
			
		||||
            app.config['REMEMBER_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
 | 
			
		||||
            app.config['SESSION_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    @app.route("/", methods=['GET'])
 | 
			
		||||
    @login_optionally_required
 | 
			
		||||
    def index():
 | 
			
		||||
        global datastore
 | 
			
		||||
        from changedetectionio import forms
 | 
			
		||||
 | 
			
		||||
        active_tag_req = request.args.get('tag', '').lower().strip()
 | 
			
		||||
        active_tag_uuid = active_tag = None
 | 
			
		||||
 | 
			
		||||
        # Be sure limit_tag is a uuid
 | 
			
		||||
        if active_tag_req:
 | 
			
		||||
            for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
 | 
			
		||||
                if active_tag_req == tag.get('title', '').lower().strip() or active_tag_req == uuid:
 | 
			
		||||
                    active_tag = tag
 | 
			
		||||
                    active_tag_uuid = uuid
 | 
			
		||||
                    break
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        # Redirect for the old rss path which used the /?rss=true
 | 
			
		||||
        if request.args.get('rss'):
 | 
			
		||||
            return redirect(url_for('rss.feed', tag=active_tag_uuid))
 | 
			
		||||
 | 
			
		||||
        op = request.args.get('op')
 | 
			
		||||
        if op:
 | 
			
		||||
            uuid = request.args.get('uuid')
 | 
			
		||||
            if op == 'pause':
 | 
			
		||||
                datastore.data['watching'][uuid].toggle_pause()
 | 
			
		||||
            elif op == 'mute':
 | 
			
		||||
                datastore.data['watching'][uuid].toggle_mute()
 | 
			
		||||
 | 
			
		||||
            datastore.needs_write = True
 | 
			
		||||
            return redirect(url_for('index', tag = active_tag_uuid))
 | 
			
		||||
 | 
			
		||||
        # Sort by last_changed and add the uuid which is usually the key..
 | 
			
		||||
        sorted_watches = []
 | 
			
		||||
        with_errors = request.args.get('with_errors') == "1"
 | 
			
		||||
        errored_count = 0
 | 
			
		||||
        search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
 | 
			
		||||
        for uuid, watch in datastore.data['watching'].items():
 | 
			
		||||
            if with_errors and not watch.get('last_error'):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if active_tag_uuid and not active_tag_uuid in watch['tags']:
 | 
			
		||||
                    continue
 | 
			
		||||
            if watch.get('last_error'):
 | 
			
		||||
                errored_count += 1
 | 
			
		||||
 | 
			
		||||
            if search_q:
 | 
			
		||||
                if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
 | 
			
		||||
                    sorted_watches.append(watch)
 | 
			
		||||
                elif watch.get('last_error') and search_q in watch.get('last_error').lower():
 | 
			
		||||
                    sorted_watches.append(watch)
 | 
			
		||||
            else:
 | 
			
		||||
                sorted_watches.append(watch)
 | 
			
		||||
 | 
			
		||||
        form = forms.quickWatchForm(request.form)
 | 
			
		||||
        page = request.args.get(get_page_parameter(), type=int, default=1)
 | 
			
		||||
        total_count = len(sorted_watches)
 | 
			
		||||
 | 
			
		||||
        pagination = Pagination(page=page,
 | 
			
		||||
                                total=total_count,
 | 
			
		||||
                                per_page=datastore.data['settings']['application'].get('pager_size', 50), css_framework="semantic")
 | 
			
		||||
 | 
			
		||||
        sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
 | 
			
		||||
        output = render_template(
 | 
			
		||||
            "watch-overview.html",
 | 
			
		||||
                                 # Don't link to hosting when we're on the hosting environment
 | 
			
		||||
                                 active_tag=active_tag,
 | 
			
		||||
                                 active_tag_uuid=active_tag_uuid,
 | 
			
		||||
                                 app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
 | 
			
		||||
                                 datastore=datastore,
 | 
			
		||||
                                 errored_count=errored_count,
 | 
			
		||||
                                 form=form,
 | 
			
		||||
                                 guid=datastore.data['app_guid'],
 | 
			
		||||
                                 has_proxies=datastore.proxy_list,
 | 
			
		||||
                                 has_unviewed=datastore.has_unviewed,
 | 
			
		||||
                                 hosted_sticky=os.getenv("SALTED_PASS", False) == False,
 | 
			
		||||
                                 pagination=pagination,
 | 
			
		||||
                                 queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
 | 
			
		||||
                                 search_q=request.args.get('q','').strip(),
 | 
			
		||||
                                 sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
 | 
			
		||||
                                 sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
 | 
			
		||||
                                 system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
 | 
			
		||||
                                 tags=sorted_tags,
 | 
			
		||||
                                 watches=sorted_watches
 | 
			
		||||
                                 )
 | 
			
		||||
 | 
			
		||||
        if session.get('share-link'):
 | 
			
		||||
            del(session['share-link'])
 | 
			
		||||
 | 
			
		||||
        resp = make_response(output)
 | 
			
		||||
 | 
			
		||||
        # The template can run on cookie or url query info
 | 
			
		||||
        if request.args.get('sort'):
 | 
			
		||||
            resp.set_cookie('sort', request.args.get('sort'))
 | 
			
		||||
        if request.args.get('order'):
 | 
			
		||||
            resp.set_cookie('order', request.args.get('order'))
 | 
			
		||||
 | 
			
		||||
        return resp
 | 
			
		||||
 | 
			
		||||
    @app.route("/static/<string:group>/<string:filename>", methods=['GET'])
 | 
			
		||||
    def static_content(group, filename):
 | 
			
		||||
        from flask import make_response
 | 
			
		||||
        import re
 | 
			
		||||
        group = re.sub(r'[^\w.-]+', '', group.lower())
 | 
			
		||||
        filename = re.sub(r'[^\w.-]+', '', filename.lower())
 | 
			
		||||
 | 
			
		||||
        if group == 'screenshot':
 | 
			
		||||
            # Could be sensitive, follow password requirements
 | 
			
		||||
            if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
 | 
			
		||||
                abort(403)
 | 
			
		||||
                if not datastore.data['settings']['application'].get('shared_diff_access'):
 | 
			
		||||
                    abort(403)
 | 
			
		||||
 | 
			
		||||
            screenshot_filename = "last-screenshot.png" if not request.args.get('error_screenshot') else "last-error-screenshot.png"
 | 
			
		||||
 | 
			
		||||
@@ -495,7 +409,7 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
        # These files should be in our subdirectory
 | 
			
		||||
        try:
 | 
			
		||||
            return send_from_directory("static/{}".format(group), path=filename)
 | 
			
		||||
            return send_from_directory(f"static/{group}", path=filename)
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            abort(404)
 | 
			
		||||
 | 
			
		||||
@@ -524,12 +438,15 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
    import changedetectionio.conditions.blueprint as conditions
 | 
			
		||||
    app.register_blueprint(conditions.construct_blueprint(datastore), url_prefix='/conditions')
 | 
			
		||||
 | 
			
		||||
    import changedetectionio.blueprint.rss as rss
 | 
			
		||||
    import changedetectionio.blueprint.rss.blueprint as rss
 | 
			
		||||
    app.register_blueprint(rss.construct_blueprint(datastore), url_prefix='/rss')
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    # watchlist UI buttons etc
 | 
			
		||||
    import changedetectionio.blueprint.ui as ui
 | 
			
		||||
    app.register_blueprint(ui.construct_blueprint(datastore, update_q, running_update_threads, queuedWatchMetaData))
 | 
			
		||||
 | 
			
		||||
    import changedetectionio.blueprint.watchlist as watchlist
 | 
			
		||||
    app.register_blueprint(watchlist.construct_blueprint(datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData), url_prefix='')
 | 
			
		||||
 | 
			
		||||
    # @todo handle ctrl break
 | 
			
		||||
    ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import re
 | 
			
		||||
from loguru import logger
 | 
			
		||||
from wtforms.widgets.core import TimeInput
 | 
			
		||||
 | 
			
		||||
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES
 | 
			
		||||
from changedetectionio.conditions.form import ConditionFormRow
 | 
			
		||||
from changedetectionio.strtobool import strtobool
 | 
			
		||||
 | 
			
		||||
@@ -305,10 +306,10 @@ class ValidateAppRiseServers(object):
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form, field):
 | 
			
		||||
        import apprise
 | 
			
		||||
        apobj = apprise.Apprise()
 | 
			
		||||
        from .apprise_plugin.assets import apprise_asset
 | 
			
		||||
        from .apprise_plugin.custom_handlers import apprise_http_custom_handler  # noqa: F401
 | 
			
		||||
 | 
			
		||||
        # so that the custom endpoints are registered
 | 
			
		||||
        from .apprise_asset import asset
 | 
			
		||||
        apobj = apprise.Apprise(asset=apprise_asset)
 | 
			
		||||
 | 
			
		||||
        for server_url in field.data:
 | 
			
		||||
            url = server_url.strip()
 | 
			
		||||
@@ -739,6 +740,9 @@ class globalSettingsApplicationForm(commonSettingsForm):
 | 
			
		||||
                              render_kw={"style": "width: 5em;"},
 | 
			
		||||
                              validators=[validators.NumberRange(min=0,
 | 
			
		||||
                                                                 message="Should be atleast zero (disabled)")])
 | 
			
		||||
 | 
			
		||||
    rss_content_format = SelectField('RSS Content format', choices=RSS_FORMAT_TYPES)
 | 
			
		||||
 | 
			
		||||
    removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"})
 | 
			
		||||
    render_anchor_tag_content = BooleanField('Render anchor tag content', default=False)
 | 
			
		||||
    shared_diff_access = BooleanField('Allow access to view diff page when password is enabled', default=False, validators=[validators.Optional()])
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,7 @@
 | 
			
		||||
from os import getenv
 | 
			
		||||
 | 
			
		||||
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES
 | 
			
		||||
 | 
			
		||||
from changedetectionio.notification import (
 | 
			
		||||
    default_notification_body,
 | 
			
		||||
    default_notification_format,
 | 
			
		||||
@@ -9,6 +12,8 @@ from changedetectionio.notification import (
 | 
			
		||||
_FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6
 | 
			
		||||
DEFAULT_SETTINGS_HEADERS_USERAGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class model(dict):
 | 
			
		||||
    base_config = {
 | 
			
		||||
            'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!",
 | 
			
		||||
@@ -48,6 +53,7 @@ class model(dict):
 | 
			
		||||
                    'password': False,
 | 
			
		||||
                    'render_anchor_tag_content': False,
 | 
			
		||||
                    'rss_access_token': None,
 | 
			
		||||
                    'rss_content_format': RSS_FORMAT_TYPES[0][0],
 | 
			
		||||
                    'rss_hide_muted_watches': True,
 | 
			
		||||
                    'schema_version' : 0,
 | 
			
		||||
                    'shared_diff_access': False,
 | 
			
		||||
 
 | 
			
		||||
@@ -575,7 +575,7 @@ class model(watch_base):
 | 
			
		||||
        import brotli
 | 
			
		||||
        filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
 | 
			
		||||
 | 
			
		||||
        if not os.path.isfile(filepath):
 | 
			
		||||
        if not os.path.isfile(filepath) or os.path.getsize(filepath) == 0:
 | 
			
		||||
            # If a previous attempt doesnt yet exist, just snarf the previous snapshot instead
 | 
			
		||||
            dates = list(self.history.keys())
 | 
			
		||||
            if len(dates):
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,9 @@ from apprise import NotifyFormat
 | 
			
		||||
import apprise
 | 
			
		||||
from loguru import logger
 | 
			
		||||
 | 
			
		||||
from .apprise_plugin.assets import APPRISE_AVATAR_URL
 | 
			
		||||
from .apprise_plugin.custom_handlers import apprise_http_custom_handler  # noqa: F401
 | 
			
		||||
from .safe_jinja import render as jinja_render
 | 
			
		||||
 | 
			
		||||
valid_tokens = {
 | 
			
		||||
    'base_url': '',
 | 
			
		||||
@@ -39,10 +42,6 @@ valid_notification_formats = {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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()
 | 
			
		||||
    if n_object.get('notification_timestamp'):
 | 
			
		||||
        logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s")
 | 
			
		||||
@@ -66,12 +65,12 @@ def process_notification(n_object, datastore):
 | 
			
		||||
    # raise it as an exception
 | 
			
		||||
 | 
			
		||||
    sent_objs = []
 | 
			
		||||
    from .apprise_asset import asset
 | 
			
		||||
    from .apprise_plugin.assets import apprise_asset
 | 
			
		||||
 | 
			
		||||
    if 'as_async' in n_object:
 | 
			
		||||
        asset.async_mode = n_object.get('as_async')
 | 
			
		||||
        apprise_asset.async_mode = n_object.get('as_async')
 | 
			
		||||
 | 
			
		||||
    apobj = apprise.Apprise(debug=True, asset=asset)
 | 
			
		||||
    apobj = apprise.Apprise(debug=True, asset=apprise_asset)
 | 
			
		||||
 | 
			
		||||
    if not n_object.get('notification_urls'):
 | 
			
		||||
        return None
 | 
			
		||||
@@ -112,7 +111,7 @@ def process_notification(n_object, datastore):
 | 
			
		||||
                    and not url.startswith('get') \
 | 
			
		||||
                    and not url.startswith('delete') \
 | 
			
		||||
                    and not url.startswith('put'):
 | 
			
		||||
                url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png'
 | 
			
		||||
                url += k + f"avatar_url={APPRISE_AVATAR_URL}"
 | 
			
		||||
 | 
			
		||||
            if url.startswith('tgram://'):
 | 
			
		||||
                # Telegram only supports a limit subset of HTML, remove the '<br>' we place in.
 | 
			
		||||
 
 | 
			
		||||
@@ -334,12 +334,14 @@ class perform_site_check(difference_detection_processor):
 | 
			
		||||
 | 
			
		||||
        # And check if 'conditions' will let this pass through
 | 
			
		||||
        if watch.get('conditions') and watch.get('conditions_match_logic'):
 | 
			
		||||
            if not execute_ruleset_against_all_plugins(current_watch_uuid=watch.get('uuid'),
 | 
			
		||||
                                                application_datastruct=self.datastore.data,
 | 
			
		||||
                                                ephemeral_data={
 | 
			
		||||
                                                    'text': stripped_text_from_html
 | 
			
		||||
                                                }
 | 
			
		||||
                                                ):
 | 
			
		||||
            conditions_result = execute_ruleset_against_all_plugins(current_watch_uuid=watch.get('uuid'),
 | 
			
		||||
                                                                    application_datastruct=self.datastore.data,
 | 
			
		||||
                                                                    ephemeral_data={
 | 
			
		||||
                                                                        'text': stripped_text_from_html
 | 
			
		||||
                                                                    }
 | 
			
		||||
                                                                    )
 | 
			
		||||
 | 
			
		||||
            if not conditions_result.get('result'):
 | 
			
		||||
                # Conditions say "Condition not met" so we block it.
 | 
			
		||||
                blocked = True
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -52,7 +52,7 @@ $(document).ready(function () {
 | 
			
		||||
 | 
			
		||||
            
 | 
			
		||||
            // Create a rule object
 | 
			
		||||
            const rule = {
 | 
			
		||||
            let rule = {
 | 
			
		||||
                field: field,
 | 
			
		||||
                operator: operator,
 | 
			
		||||
                value: value
 | 
			
		||||
@@ -96,6 +96,10 @@ $(document).ready(function () {
 | 
			
		||||
                contentType: false, // Let the browser set the correct content type
 | 
			
		||||
                success: function (response) {
 | 
			
		||||
                    if (response.status === "success") {
 | 
			
		||||
                        if(rule['field'] !== "page_filtered_text") {
 | 
			
		||||
                            // A little debug helper for the user
 | 
			
		||||
                            $('#verify-state-text').text(`${rule['field']} was value "${response.data[rule['field']]}"`)
 | 
			
		||||
                        }
 | 
			
		||||
                        if (response.result) {
 | 
			
		||||
                            alert("✅ Condition PASSES verification against current snapshot!");
 | 
			
		||||
                        } else {
 | 
			
		||||
 
 | 
			
		||||
@@ -48,6 +48,8 @@ $(function () {
 | 
			
		||||
        $('input[type=checkbox]').not(this).prop('checked', this.checked);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const time_check_step_size_seconds=1;
 | 
			
		||||
 | 
			
		||||
    // checkboxes - show/hide buttons
 | 
			
		||||
    $("input[type=checkbox]").click(function (e) {
 | 
			
		||||
        if ($('input[type=checkbox]:checked').length) {
 | 
			
		||||
@@ -57,5 +59,30 @@ $(function () {
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setInterval(function () {
 | 
			
		||||
        // Background ETA completion for 'checking now'
 | 
			
		||||
        $(".watch-table .checking-now .last-checked").each(function () {
 | 
			
		||||
            const eta_complete = parseFloat($(this).data('eta_complete'));
 | 
			
		||||
            const fetch_duration = parseInt($(this).data('fetchduration'));
 | 
			
		||||
 | 
			
		||||
            if (eta_complete + 2 > nowtimeserver && fetch_duration > 3) {
 | 
			
		||||
                const remaining_seconds = Math.abs(eta_complete) - nowtimeserver - 1;
 | 
			
		||||
 | 
			
		||||
                let r = (1.0 - (remaining_seconds / fetch_duration)) * 100;
 | 
			
		||||
                if (r < 10) {
 | 
			
		||||
                    r = 10;
 | 
			
		||||
                }
 | 
			
		||||
                if (r >= 90) {
 | 
			
		||||
                    r = 100;
 | 
			
		||||
                }
 | 
			
		||||
                $(this).css('background-size', `${r}% 100%`);
 | 
			
		||||
                //$(this).text(`${r}% remain ${remaining_seconds}`);
 | 
			
		||||
            } else {
 | 
			
		||||
                $(this).css('background-size', `100% 100%`);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        nowtimeserver = nowtimeserver + time_check_step_size_seconds;
 | 
			
		||||
    }, time_check_step_size_seconds * 1000);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -251,8 +251,14 @@ class ChangeDetectionStore:
 | 
			
		||||
    # Clone a watch by UUID
 | 
			
		||||
    def clone(self, uuid):
 | 
			
		||||
        url = self.data['watching'][uuid].get('url')
 | 
			
		||||
        extras = self.data['watching'][uuid]
 | 
			
		||||
        extras = deepcopy(self.data['watching'][uuid])
 | 
			
		||||
        new_uuid = self.add_watch(url=url, extras=extras)
 | 
			
		||||
        watch = self.data['watching'][new_uuid]
 | 
			
		||||
 | 
			
		||||
        if self.data['settings']['application'].get('extract_title_as_title') or watch['extract_title_as_title']:
 | 
			
		||||
            # Because it will be recalculated on the next fetch
 | 
			
		||||
            self.data['watching'][new_uuid]['title'] = None
 | 
			
		||||
 | 
			
		||||
        return new_uuid
 | 
			
		||||
 | 
			
		||||
    def url_exists(self, url):
 | 
			
		||||
@@ -363,7 +369,6 @@ class ChangeDetectionStore:
 | 
			
		||||
        new_watch.ensure_data_dir_exists()
 | 
			
		||||
        self.__data['watching'][new_uuid] = new_watch
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        if write_to_disk_now:
 | 
			
		||||
            self.sync_to_json()
 | 
			
		||||
 | 
			
		||||
@@ -571,16 +576,16 @@ class ChangeDetectionStore:
 | 
			
		||||
 | 
			
		||||
        return ret
 | 
			
		||||
 | 
			
		||||
    def add_tag(self, name):
 | 
			
		||||
    def add_tag(self, title):
 | 
			
		||||
        # If name exists, return that
 | 
			
		||||
        n = name.strip().lower()
 | 
			
		||||
        n = title.strip().lower()
 | 
			
		||||
        logger.debug(f">>> Adding new tag - '{n}'")
 | 
			
		||||
        if not n:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
 | 
			
		||||
            if n == tag.get('title', '').lower().strip():
 | 
			
		||||
                logger.warning(f"Tag '{name}' already exists, skipping creation.")
 | 
			
		||||
                logger.warning(f"Tag '{title}' already exists, skipping creation.")
 | 
			
		||||
                return uuid
 | 
			
		||||
 | 
			
		||||
        # Eventually almost everything todo with a watch will apply as a Tag
 | 
			
		||||
@@ -588,7 +593,7 @@ class ChangeDetectionStore:
 | 
			
		||||
        with self.lock:
 | 
			
		||||
            from .model import Tag
 | 
			
		||||
            new_tag = Tag.model(datastore_path=self.datastore_path, default={
 | 
			
		||||
                'title': name.strip(),
 | 
			
		||||
                'title': title.strip(),
 | 
			
		||||
                'date_created': int(time.time())
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
@@ -631,6 +636,41 @@ class ChangeDetectionStore:
 | 
			
		||||
            if watch.get('processor') == processor_name:
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
        
 | 
			
		||||
    def search_watches_for_url(self, query, tag_limit=None, partial=False):
 | 
			
		||||
        """Search watches by URL, title, or error messages
 | 
			
		||||
        
 | 
			
		||||
        Args:
 | 
			
		||||
            query (str): Search term to match against watch URLs, titles, and error messages
 | 
			
		||||
            tag_limit (str, optional): Optional tag name to limit search results
 | 
			
		||||
            partial: (bool, optional): sub-string matching
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            list: List of UUIDs of watches that match the search criteria
 | 
			
		||||
        """
 | 
			
		||||
        matching_uuids = []
 | 
			
		||||
        query = query.lower().strip()
 | 
			
		||||
        tag = self.tag_exists_by_name(tag_limit) if tag_limit else False
 | 
			
		||||
 | 
			
		||||
        for uuid, watch in self.data['watching'].items():
 | 
			
		||||
            # Filter by tag if requested
 | 
			
		||||
            if tag_limit:
 | 
			
		||||
                if not tag.get('uuid') in watch.get('tags', []):
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
            # Search in URL, title, or error messages
 | 
			
		||||
            if partial:
 | 
			
		||||
                if ((watch.get('title') and query in watch.get('title').lower()) or
 | 
			
		||||
                    query in watch.get('url', '').lower() or
 | 
			
		||||
                    (watch.get('last_error') and query in watch.get('last_error').lower())):
 | 
			
		||||
                    matching_uuids.append(uuid)
 | 
			
		||||
            else:
 | 
			
		||||
                if ((watch.get('title') and query == watch.get('title').lower()) or
 | 
			
		||||
                        query == watch.get('url', '').lower() or
 | 
			
		||||
                        (watch.get('last_error') and query == watch.get('last_error').lower())):
 | 
			
		||||
                    matching_uuids.append(uuid)
 | 
			
		||||
 | 
			
		||||
        return matching_uuids
 | 
			
		||||
 | 
			
		||||
    def get_unique_notification_tokens_available(self):
 | 
			
		||||
        # Ask each type of watch if they have any extra notification token to add to the validation
 | 
			
		||||
@@ -847,7 +887,7 @@ class ChangeDetectionStore:
 | 
			
		||||
            if tag:
 | 
			
		||||
                tag_uuids = []
 | 
			
		||||
                for t in tag.split(','):
 | 
			
		||||
                    tag_uuids.append(self.add_tag(name=t))
 | 
			
		||||
                    tag_uuids.append(self.add_tag(title=t))
 | 
			
		||||
 | 
			
		||||
                self.data['watching'][uuid]['tags'] = tag_uuids
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -42,7 +42,7 @@
 | 
			
		||||
          <a class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
 | 
			
		||||
            <strong>Change</strong>Detection.io</a>
 | 
			
		||||
        {% else %}
 | 
			
		||||
          <a class="pure-menu-heading" href="{{url_for('index')}}">
 | 
			
		||||
          <a class="pure-menu-heading" href="{{url_for('watchlist.index')}}">
 | 
			
		||||
            <strong>Change</strong>Detection.io</a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if current_diff_url %}
 | 
			
		||||
@@ -157,15 +157,13 @@
 | 
			
		||||
                <h4>Try our Chrome extension</h4>
 | 
			
		||||
                <p>
 | 
			
		||||
                    <a id="chrome-extension-link"
 | 
			
		||||
                       title="Try our new Chrome Extension!"
 | 
			
		||||
                       title="Chrome Extension - Web Page Change Detection with changedetection.io!"
 | 
			
		||||
                       href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop">
 | 
			
		||||
                        <img alt="Chrome store icon" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}">
 | 
			
		||||
                        Chrome Webstore
 | 
			
		||||
                    </a>
 | 
			
		||||
                </p>
 | 
			
		||||
 | 
			
		||||
                Easily add the current web-page from your browser directly into your changedetection.io tool, more great features coming soon!
 | 
			
		||||
 | 
			
		||||
                <h4>Changedetection.io needs your support!</h4>
 | 
			
		||||
                <p>
 | 
			
		||||
                    You can help us by supporting changedetection.io on these platforms;
 | 
			
		||||
@@ -173,17 +171,20 @@
 | 
			
		||||
                <p>
 | 
			
		||||
                <ul>
 | 
			
		||||
                    <li>
 | 
			
		||||
                        <a href="https://alternativeto.net/software/changedetection-io/about/">Rate us at
 | 
			
		||||
                        <a href="https://alternativeto.net/software/changedetection-io/about/" title="Web page change detection at alternativeto.net">Rate us at
 | 
			
		||||
                        AlternativeTo.net</a>
 | 
			
		||||
                    </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a href="https://github.com/dgtlmoon/changedetection.io">Star us on GitHub</a>
 | 
			
		||||
                    <a href="https://github.com/dgtlmoon/changedetection.io" title="Web page change detection on GitHub">Star us on GitHub</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a href="https://twitter.com/change_det_io">Follow us at Twitter/X</a>
 | 
			
		||||
                    <a rel="nofollow" href="https://twitter.com/change_det_io" title="Web page change detection on Twitter">Follow us at Twitter/X</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a href="https://www.linkedin.com/company/changedetection-io">Check us out on LinkedIn</a>
 | 
			
		||||
                    <a rel="nofollow" href="https://www.g2.com/products/changedetection-io/reviews" title="Web page change detection reviews at G2">G2 Software reviews</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    <a rel="nofollow" href="https://www.linkedin.com/company/changedetection-io" title="Visit web page change detection at LinkedIn">Check us out on LinkedIn</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                    And tell your friends and colleagues :)
 | 
			
		||||
 
 | 
			
		||||
@@ -305,9 +305,9 @@ Math: {{ 1 + 1 }}") }}
 | 
			
		||||
                    {{ render_field(form.conditions_match_logic) }}
 | 
			
		||||
                    {{ render_fieldlist_of_formfields_as_table(form.conditions) }}
 | 
			
		||||
                    <div class="pure-form-message-inline">
 | 
			
		||||
                        <br>
 | 
			
		||||
                        Use the verify (✓) button to test if a condition passes against the current snapshot.<br><br>
 | 
			
		||||
                        Did you know that <strong>conditions</strong> can be extended with your own custom plugin? tutorials coming soon!<br>
 | 
			
		||||
 | 
			
		||||
                        <p id="verify-state-text">Use the verify (✓) button to test if a condition passes against the current snapshot.</p>
 | 
			
		||||
                       Read a quick tutorial about <a href="https://changedetection.io/tutorial/conditional-actions-web-page-changes">using conditional web page changes here</a>.<br>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
@@ -337,7 +337,7 @@ 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 CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
 | 
			
		||||
                        <span class="pure-form-message-inline">One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
 | 
			
		||||
                        <span data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</span><br>
 | 
			
		||||
                    <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>
 | 
			
		||||
@@ -588,10 +588,10 @@ keyword") }}
 | 
			
		||||
                    {{ render_button(form.save_button) }}
 | 
			
		||||
                    <a href="{{url_for('ui.form_delete', uuid=uuid)}}"
 | 
			
		||||
                       class="pure-button button-small button-error ">Delete</a>
 | 
			
		||||
                    <a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}"
 | 
			
		||||
                       class="pure-button button-small button-error ">Clear History</a>
 | 
			
		||||
                    {% if watch.history_n %}<a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}"
 | 
			
		||||
                       class="pure-button button-small button-error ">Clear History</a>{% endif %}
 | 
			
		||||
                    <a href="{{url_for('ui.form_clone', uuid=uuid)}}"
 | 
			
		||||
                       class="pure-button button-small ">Create Copy</a>
 | 
			
		||||
                       class="pure-button button-small ">Clone & Edit</a>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </form>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										24
									
								
								changedetectionio/tests/apprise/test_apprise_asset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								changedetectionio/tests/apprise/test_apprise_asset.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
import pytest
 | 
			
		||||
from apprise import AppriseAsset
 | 
			
		||||
 | 
			
		||||
from changedetectionio.apprise_asset import (
 | 
			
		||||
    APPRISE_APP_DESC,
 | 
			
		||||
    APPRISE_APP_ID,
 | 
			
		||||
    APPRISE_APP_URL,
 | 
			
		||||
    APPRISE_AVATAR_URL,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture(scope="function")
 | 
			
		||||
def apprise_asset() -> AppriseAsset:
 | 
			
		||||
    from changedetectionio.apprise_asset import apprise_asset
 | 
			
		||||
 | 
			
		||||
    return apprise_asset
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_apprise_asset_init(apprise_asset: AppriseAsset):
 | 
			
		||||
    assert isinstance(apprise_asset, AppriseAsset)
 | 
			
		||||
    assert apprise_asset.app_id == APPRISE_APP_ID
 | 
			
		||||
    assert apprise_asset.app_desc == APPRISE_APP_DESC
 | 
			
		||||
    assert apprise_asset.app_url == APPRISE_APP_URL
 | 
			
		||||
    assert apprise_asset.image_url_logo == APPRISE_AVATAR_URL
 | 
			
		||||
							
								
								
									
										211
									
								
								changedetectionio/tests/apprise/test_apprise_custom_api_call.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										211
									
								
								changedetectionio/tests/apprise/test_apprise_custom_api_call.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,211 @@
 | 
			
		||||
import json
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
import requests
 | 
			
		||||
from apprise.utils.parse import parse_url as apprise_parse_url
 | 
			
		||||
 | 
			
		||||
from ...apprise_plugin.custom_handlers import (
 | 
			
		||||
    _get_auth,
 | 
			
		||||
    _get_headers,
 | 
			
		||||
    _get_params,
 | 
			
		||||
    apprise_http_custom_handler,
 | 
			
		||||
    SUPPORTED_HTTP_METHODS,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    "url,expected_auth",
 | 
			
		||||
    [
 | 
			
		||||
        ("get://user:pass@localhost:9999", ("user", "pass")),
 | 
			
		||||
        ("get://user@localhost:9999", "user"),
 | 
			
		||||
        ("get://localhost:9999", ""),
 | 
			
		||||
        ("get://user%20name:pass%20word@localhost:9999", ("user name", "pass word")),
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
def test_get_auth(url, expected_auth):
 | 
			
		||||
    """Test authentication extraction with various URL formats."""
 | 
			
		||||
    parsed_url = apprise_parse_url(url)
 | 
			
		||||
    assert _get_auth(parsed_url) == expected_auth
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    "url,body,expected_content_type",
 | 
			
		||||
    [
 | 
			
		||||
        (
 | 
			
		||||
            "get://localhost:9999?+content-type=application/xml",
 | 
			
		||||
            "test",
 | 
			
		||||
            "application/xml",
 | 
			
		||||
        ),
 | 
			
		||||
        ("get://localhost:9999", '{"key": "value"}', "application/json; charset=utf-8"),
 | 
			
		||||
        ("get://localhost:9999", "plain text", None),
 | 
			
		||||
        ("get://localhost:9999?+content-type=text/plain", "test", "text/plain"),
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
def test_get_headers(url, body, expected_content_type):
 | 
			
		||||
    """Test header extraction and content type detection."""
 | 
			
		||||
    parsed_url = apprise_parse_url(url)
 | 
			
		||||
    headers = _get_headers(parsed_url, body)
 | 
			
		||||
 | 
			
		||||
    if expected_content_type:
 | 
			
		||||
        assert headers.get("Content-Type") == expected_content_type
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    "url,expected_params",
 | 
			
		||||
    [
 | 
			
		||||
        ("get://localhost:9999?param1=value1", {"param1": "value1"}),
 | 
			
		||||
        ("get://localhost:9999?param1=value1&-param2=ignored", {"param1": "value1"}),
 | 
			
		||||
        ("get://localhost:9999?param1=value1&+header=test", {"param1": "value1"}),
 | 
			
		||||
        (
 | 
			
		||||
            "get://localhost:9999?encoded%20param=encoded%20value",
 | 
			
		||||
            {"encoded param": "encoded value"},
 | 
			
		||||
        ),
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
def test_get_params(url, expected_params):
 | 
			
		||||
    """Test parameter extraction with URL encoding and exclusion logic."""
 | 
			
		||||
    parsed_url = apprise_parse_url(url)
 | 
			
		||||
    params = _get_params(parsed_url)
 | 
			
		||||
    assert dict(params) == expected_params
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    "url,schema,method",
 | 
			
		||||
    [
 | 
			
		||||
        ("get://localhost:9999", "get", "GET"),
 | 
			
		||||
        ("post://localhost:9999", "post", "POST"),
 | 
			
		||||
        ("delete://localhost:9999", "delete", "DELETE"),
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
@patch("requests.request")
 | 
			
		||||
def test_apprise_custom_api_call_success(mock_request, url, schema, method):
 | 
			
		||||
    """Test successful API calls with different HTTP methods and schemas."""
 | 
			
		||||
    mock_request.return_value.raise_for_status.return_value = None
 | 
			
		||||
 | 
			
		||||
    meta = {"url": url, "schema": schema}
 | 
			
		||||
    result = apprise_http_custom_handler(
 | 
			
		||||
        body="test body", title="Test Title", notify_type="info", meta=meta
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert result is True
 | 
			
		||||
    mock_request.assert_called_once()
 | 
			
		||||
 | 
			
		||||
    call_args = mock_request.call_args
 | 
			
		||||
    assert call_args[1]["method"] == method.upper()
 | 
			
		||||
    assert call_args[1]["url"].startswith("http")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@patch("requests.request")
 | 
			
		||||
def test_apprise_custom_api_call_with_auth(mock_request):
 | 
			
		||||
    """Test API call with authentication."""
 | 
			
		||||
    mock_request.return_value.raise_for_status.return_value = None
 | 
			
		||||
 | 
			
		||||
    url = "get://user:pass@localhost:9999/secure"
 | 
			
		||||
    meta = {"url": url, "schema": "get"}
 | 
			
		||||
 | 
			
		||||
    result = apprise_http_custom_handler(
 | 
			
		||||
        body=json.dumps({"key": "value"}),
 | 
			
		||||
        title="Secure Test",
 | 
			
		||||
        notify_type="info",
 | 
			
		||||
        meta=meta,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert result is True
 | 
			
		||||
    mock_request.assert_called_once()
 | 
			
		||||
    call_args = mock_request.call_args
 | 
			
		||||
    assert call_args[1]["auth"] == ("user", "pass")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    "exception_type,expected_result",
 | 
			
		||||
    [
 | 
			
		||||
        (requests.RequestException, False),
 | 
			
		||||
        (requests.HTTPError, False),
 | 
			
		||||
        (Exception, False),
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
@patch("requests.request")
 | 
			
		||||
def test_apprise_custom_api_call_failure(mock_request, exception_type, expected_result):
 | 
			
		||||
    """Test various failure scenarios."""
 | 
			
		||||
    url = "get://localhost:9999/error"
 | 
			
		||||
    meta = {"url": url, "schema": "get"}
 | 
			
		||||
 | 
			
		||||
    # Simulate different types of exceptions
 | 
			
		||||
    mock_request.side_effect = exception_type("Error occurred")
 | 
			
		||||
 | 
			
		||||
    result = apprise_http_custom_handler(
 | 
			
		||||
        body="error body", title="Error Test", notify_type="error", meta=meta
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert result == expected_result
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_invalid_url_parsing():
 | 
			
		||||
    """Test handling of invalid URL parsing."""
 | 
			
		||||
    meta = {"url": "invalid://url", "schema": "invalid"}
 | 
			
		||||
    result = apprise_http_custom_handler(
 | 
			
		||||
        body="test", title="Invalid URL", notify_type="info", meta=meta
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert result is False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    "schema,expected_method",
 | 
			
		||||
    [
 | 
			
		||||
        (http_method, http_method.upper())
 | 
			
		||||
        for http_method in SUPPORTED_HTTP_METHODS
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
@patch("requests.request")
 | 
			
		||||
def test_http_methods(mock_request, schema, expected_method):
 | 
			
		||||
    """Test all supported HTTP methods."""
 | 
			
		||||
    mock_request.return_value.raise_for_status.return_value = None
 | 
			
		||||
 | 
			
		||||
    url = f"{schema}://localhost:9999"
 | 
			
		||||
 | 
			
		||||
    result = apprise_http_custom_handler(
 | 
			
		||||
        body="test body",
 | 
			
		||||
        title="Test Title",
 | 
			
		||||
        notify_type="info",
 | 
			
		||||
        meta={"url": url, "schema": schema},
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert result is True
 | 
			
		||||
    mock_request.assert_called_once()
 | 
			
		||||
 | 
			
		||||
    call_args = mock_request.call_args
 | 
			
		||||
    assert call_args[1]["method"] == expected_method
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    "input_schema,expected_method",
 | 
			
		||||
    [
 | 
			
		||||
        (f"{http_method}s", http_method.upper())
 | 
			
		||||
        for http_method in SUPPORTED_HTTP_METHODS
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
@patch("requests.request")
 | 
			
		||||
def test_https_method_conversion(
 | 
			
		||||
    mock_request, input_schema, expected_method
 | 
			
		||||
):
 | 
			
		||||
    """Validate that methods ending with 's' use HTTPS and correct HTTP method."""
 | 
			
		||||
    mock_request.return_value.raise_for_status.return_value = None
 | 
			
		||||
 | 
			
		||||
    url = f"{input_schema}://localhost:9999"
 | 
			
		||||
 | 
			
		||||
    result = apprise_http_custom_handler(
 | 
			
		||||
        body="test body",
 | 
			
		||||
        title="Test Title",
 | 
			
		||||
        notify_type="info",
 | 
			
		||||
        meta={"url": url, "schema": input_schema},
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert result is True
 | 
			
		||||
    mock_request.assert_called_once()
 | 
			
		||||
 | 
			
		||||
    call_args = mock_request.call_args
 | 
			
		||||
 | 
			
		||||
    assert call_args[1]["method"] == expected_method
 | 
			
		||||
    assert call_args[1]["url"].startswith("https")
 | 
			
		||||
@@ -36,7 +36,7 @@ def test_select_custom(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'Proxy Authentication Required' not in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
 
 | 
			
		||||
@@ -83,14 +83,14 @@ def test_restock_detection(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    # Is it correctly show as NOT in stock?
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'not-in-stock' in res.data
 | 
			
		||||
 | 
			
		||||
    # Is it correctly shown as in stock
 | 
			
		||||
    set_back_in_stock_response()
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'not-in-stock' not in res.data
 | 
			
		||||
 | 
			
		||||
    # We should have a notification
 | 
			
		||||
@@ -107,6 +107,6 @@ def test_restock_detection(client, live_server, measure_memory_usage):
 | 
			
		||||
    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"
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'not-in-stock' in res.data, "Correctly showing NOT IN STOCK in the list after it changed from IN STOCK"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,6 @@ def test_setup(live_server):
 | 
			
		||||
 | 
			
		||||
def get_last_message_from_smtp_server():
 | 
			
		||||
    import socket
 | 
			
		||||
    global smtp_test_server
 | 
			
		||||
    port = 11080  # socket server port number
 | 
			
		||||
 | 
			
		||||
    client_socket = socket.socket()  # instantiate
 | 
			
		||||
@@ -44,7 +43,6 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
 | 
			
		||||
    # live_server_setup(live_server)
 | 
			
		||||
    set_original_response()
 | 
			
		||||
 | 
			
		||||
    global smtp_test_server
 | 
			
		||||
    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
 | 
			
		||||
 | 
			
		||||
    #####################
 | 
			
		||||
@@ -99,7 +97,6 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
 | 
			
		||||
    # https://github.com/caronc/apprise/issues/633
 | 
			
		||||
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    global smtp_test_server
 | 
			
		||||
    notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
 | 
			
		||||
    notification_body = f"""<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
from .util import live_server_setup
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
from flask import url_for
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
@@ -44,7 +44,7 @@ def test_check_access_control(app, client, live_server):
 | 
			
		||||
        assert b"Password protection enabled." in res.data
 | 
			
		||||
 | 
			
		||||
        # Check we hit the login
 | 
			
		||||
        res = c.get(url_for("index"), follow_redirects=True)
 | 
			
		||||
        res = c.get(url_for("watchlist.index"), follow_redirects=True)
 | 
			
		||||
        # Should be logged out
 | 
			
		||||
        assert b"Login" in res.data
 | 
			
		||||
 | 
			
		||||
@@ -52,6 +52,19 @@ def test_check_access_control(app, client, live_server):
 | 
			
		||||
        res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
 | 
			
		||||
        assert b'Random content' in res.data
 | 
			
		||||
 | 
			
		||||
        # access to assets should work (check_authentication)
 | 
			
		||||
        res = c.get(url_for('static_content', group='js', filename='jquery-3.6.0.min.js'))
 | 
			
		||||
        assert res.status_code == 200
 | 
			
		||||
        res = c.get(url_for('static_content', group='styles', filename='styles.css'))
 | 
			
		||||
        assert res.status_code == 200
 | 
			
		||||
        res = c.get(url_for('static_content', group='styles', filename='404-testetest.css'))
 | 
			
		||||
        assert res.status_code == 404
 | 
			
		||||
 | 
			
		||||
        # Access to screenshots should be limited by 'shared_diff_access'
 | 
			
		||||
        path = url_for('static_content', group='screenshot', filename='random-uuid-that-will-404.png', _external=True)
 | 
			
		||||
        res = c.get(path)
 | 
			
		||||
        assert res.status_code == 404
 | 
			
		||||
 | 
			
		||||
        # Check wrong password does not let us in
 | 
			
		||||
        res = c.post(
 | 
			
		||||
            url_for("login"),
 | 
			
		||||
@@ -155,7 +168,7 @@ def test_check_access_control(app, client, live_server):
 | 
			
		||||
            url_for("settings.settings_page"),
 | 
			
		||||
            data={"application-password": "foobar",
 | 
			
		||||
                  # Should be disabled
 | 
			
		||||
#                  "application-shared_diff_access": "True",
 | 
			
		||||
                  "application-shared_diff_access": "",
 | 
			
		||||
                  "requests-time_between_check-minutes": 180,
 | 
			
		||||
                  'application-fetch_backend': "html_requests"},
 | 
			
		||||
            follow_redirects=True
 | 
			
		||||
@@ -164,10 +177,14 @@ def test_check_access_control(app, client, live_server):
 | 
			
		||||
        assert b"Password protection enabled." in res.data
 | 
			
		||||
 | 
			
		||||
        # Check we hit the login
 | 
			
		||||
        res = c.get(url_for("index"), follow_redirects=True)
 | 
			
		||||
        res = c.get(url_for("watchlist.index"), follow_redirects=True)
 | 
			
		||||
        # Should be logged out
 | 
			
		||||
        assert b"Login" in res.data
 | 
			
		||||
 | 
			
		||||
        # Access to screenshots should be limited by 'shared_diff_access'
 | 
			
		||||
        res = c.get(url_for('static_content', group='screenshot', filename='random-uuid-that-will-403.png'))
 | 
			
		||||
        assert res.status_code == 403
 | 
			
		||||
 | 
			
		||||
        # The diff page should return something valid when logged out
 | 
			
		||||
        res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
 | 
			
		||||
        assert b'Random content' not in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -72,7 +72,7 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory
 | 
			
		||||
    res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'Queued 1 watch for rechecking.' in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # The trigger line is REMOVED,  this should trigger
 | 
			
		||||
@@ -81,7 +81,7 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory
 | 
			
		||||
    # Check in the processor here what's going on, its triggering empty-reply and no change.
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -90,14 +90,14 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory
 | 
			
		||||
    set_original(excluding=None)
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # Remove it again, and we should get a trigger
 | 
			
		||||
    set_original(excluding='The golden line')
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
@@ -157,14 +157,14 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
 | 
			
		||||
    assert b'Queued 1 watch for rechecking.' in res.data
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # The trigger line is ADDED,  this should trigger
 | 
			
		||||
    set_original(add_line='<p>Oh yes please</p>')
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup, extract_api_key_from_UI, wait_for_all_checks
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
import uuid
 | 
			
		||||
@@ -57,16 +57,15 @@ def test_setup(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_api_simple(client, live_server, measure_memory_usage):
 | 
			
		||||
#    live_server_setup(live_server)
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    api_key = extract_api_key_from_UI(client)
 | 
			
		||||
    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
 | 
			
		||||
 | 
			
		||||
    # Create a watch
 | 
			
		||||
    set_original_response()
 | 
			
		||||
 | 
			
		||||
    # Validate bad URL
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True,
 | 
			
		||||
                       headers={'x-api-key': api_key}, )
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True )
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("createwatch"),
 | 
			
		||||
        data=json.dumps({"url": "h://xxxxxxxxxom"}),
 | 
			
		||||
@@ -293,12 +292,11 @@ def test_access_denied(client, live_server, measure_memory_usage):
 | 
			
		||||
def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    api_key = extract_api_key_from_UI(client)
 | 
			
		||||
    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
 | 
			
		||||
 | 
			
		||||
    # Create a watch
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True,
 | 
			
		||||
                       headers={'x-api-key': api_key}, )
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
 | 
			
		||||
    # Create new
 | 
			
		||||
    res = client.post(
 | 
			
		||||
@@ -374,7 +372,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
def test_api_import(client, live_server, measure_memory_usage):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    api_key = extract_api_key_from_UI(client)
 | 
			
		||||
    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import") + "?tag=import-test",
 | 
			
		||||
@@ -385,11 +383,55 @@ def test_api_import(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert len(res.json) == 2
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b"https://website1.com" in res.data
 | 
			
		||||
    assert b"https://website2.com" in res.data
 | 
			
		||||
 | 
			
		||||
    # Should see the new tag in the tag/groups list
 | 
			
		||||
    res = client.get(url_for('tags.tags_overview_page'))
 | 
			
		||||
    assert b'import-test' in res.data
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
def test_api_conflict_UI_password(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
 | 
			
		||||
 | 
			
		||||
    # Enable password check and diff page access bypass
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings.settings_page"),
 | 
			
		||||
        data={"application-password": "foobar", # password is now set! API should still work!
 | 
			
		||||
              "application-api_access_token_enabled": "y",
 | 
			
		||||
              "requests-time_between_check-minutes": 180,
 | 
			
		||||
              'application-fetch_backend': "html_requests"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"Password protection enabled." in res.data
 | 
			
		||||
 | 
			
		||||
    # Create a watch
 | 
			
		||||
    set_original_response()
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
 | 
			
		||||
    # Create new
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("createwatch"),
 | 
			
		||||
        data=json.dumps({"url": test_url, "title": "My test URL" }),
 | 
			
		||||
        headers={'content-type': 'application/json', 'x-api-key': api_key},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert res.status_code == 201
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    url = url_for("createwatch")
 | 
			
		||||
    # Get a listing, it will be the first one
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url,
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
 | 
			
		||||
    assert len(res.json)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										101
									
								
								changedetectionio/tests/test_api_search.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								changedetectionio/tests/test_api_search.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
from copy import copy
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
import json
 | 
			
		||||
import time
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_api_search(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
 | 
			
		||||
 | 
			
		||||
    watch_data = {}
 | 
			
		||||
    # Add some test watches
 | 
			
		||||
    urls = [
 | 
			
		||||
        'https://example.com/page1',
 | 
			
		||||
        'https://example.org/testing',
 | 
			
		||||
        'https://test-site.com/example'
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    # Import the test URLs
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("imports.import_page"),
 | 
			
		||||
        data={"urls": "\r\n".join(urls)},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"3 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Get a listing, it will be the first one
 | 
			
		||||
    watches_response = client.get(
 | 
			
		||||
        url_for("createwatch"),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Add a title to one watch for title search testing
 | 
			
		||||
    for uuid, watch in watches_response.json.items():
 | 
			
		||||
 | 
			
		||||
        watch_data = client.get(url_for("watch", uuid=uuid),
 | 
			
		||||
                                follow_redirects=True,
 | 
			
		||||
                                headers={'x-api-key': api_key}
 | 
			
		||||
                                )
 | 
			
		||||
 | 
			
		||||
        if urls[0] == watch_data.json['url']:
 | 
			
		||||
            # HTTP PUT ( UPDATE an existing watch )
 | 
			
		||||
            client.put(
 | 
			
		||||
                url_for("watch", uuid=uuid),
 | 
			
		||||
                headers={'x-api-key': api_key, 'content-type': 'application/json'},
 | 
			
		||||
                data=json.dumps({'title': 'Example Title Test'}),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    # Test search by URL
 | 
			
		||||
    res = client.get(url_for("search")+"?q=https://example.com/page1", headers={'x-api-key': api_key, 'content-type': 'application/json'})
 | 
			
		||||
    assert len(res.json) == 1
 | 
			
		||||
    assert list(res.json.values())[0]['url'] == urls[0]
 | 
			
		||||
 | 
			
		||||
    # Test search by URL - partial should NOT match without ?partial=true flag
 | 
			
		||||
    res = client.get(url_for("search")+"?q=https://example", headers={'x-api-key': api_key, 'content-type': 'application/json'})
 | 
			
		||||
    assert len(res.json) == 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Test search by title
 | 
			
		||||
    res = client.get(url_for("search")+"?q=Example Title Test", headers={'x-api-key': api_key, 'content-type': 'application/json'})
 | 
			
		||||
    assert len(res.json) == 1
 | 
			
		||||
    assert list(res.json.values())[0]['url'] == urls[0]
 | 
			
		||||
    assert list(res.json.values())[0]['title'] == 'Example Title Test'
 | 
			
		||||
 | 
			
		||||
    # Test search that should return multiple results (partial = true)
 | 
			
		||||
    res = client.get(url_for("search")+"?q=https://example&partial=true", headers={'x-api-key': api_key, 'content-type': 'application/json'})
 | 
			
		||||
    assert len(res.json) == 2
 | 
			
		||||
 | 
			
		||||
    # Test empty search
 | 
			
		||||
    res = client.get(url_for("search")+"?q=", headers={'x-api-key': api_key, 'content-type': 'application/json'})
 | 
			
		||||
    assert res.status_code == 400
 | 
			
		||||
 | 
			
		||||
    # Add a tag to test search with tag filter
 | 
			
		||||
    tag_name = 'test-tag'
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("tag"),
 | 
			
		||||
        data=json.dumps({"title": tag_name}),
 | 
			
		||||
        headers={'content-type': 'application/json', 'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 201
 | 
			
		||||
    tag_uuid = res.json['uuid']
 | 
			
		||||
 | 
			
		||||
    # Add the tag to one watch
 | 
			
		||||
    for uuid, watch in watches_response.json.items():
 | 
			
		||||
        if urls[2] == watch['url']:
 | 
			
		||||
            client.put(
 | 
			
		||||
                url_for("watch", uuid=uuid),
 | 
			
		||||
                headers={'x-api-key': api_key, 'content-type': 'application/json'},
 | 
			
		||||
                data=json.dumps({'tags': [tag_uuid]}),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Test search with tag filter and q
 | 
			
		||||
    res = client.get(url_for("search") + f"?q={urls[2]}&tag={tag_name}", headers={'x-api-key': api_key, 'content-type': 'application/json'})
 | 
			
		||||
    assert len(res.json) == 1
 | 
			
		||||
    assert list(res.json.values())[0]['url'] == urls[2]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										143
									
								
								changedetectionio/tests/test_api_tags.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								changedetectionio/tests/test_api_tags.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,143 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
def test_api_tags_listing(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
 | 
			
		||||
    tag_title = 'Test Tag'
 | 
			
		||||
 | 
			
		||||
    # Get a listing
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tags"),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.text.strip() == "{}", "Should be empty list"
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("tag"),
 | 
			
		||||
        data=json.dumps({"title": tag_title}),
 | 
			
		||||
        headers={'content-type': 'application/json', 'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 201
 | 
			
		||||
 | 
			
		||||
    new_tag_uuid = res.json.get('uuid')
 | 
			
		||||
 | 
			
		||||
    # List tags - should include our new tag
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tags"),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert new_tag_uuid in res.text
 | 
			
		||||
    assert res.json[new_tag_uuid]['title'] == tag_title
 | 
			
		||||
    assert res.json[new_tag_uuid]['notification_muted'] == False
 | 
			
		||||
 | 
			
		||||
    # Get single tag
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tag", uuid=new_tag_uuid),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert res.json['title'] == tag_title
 | 
			
		||||
 | 
			
		||||
    # Update tag
 | 
			
		||||
    res = client.put(
 | 
			
		||||
        url_for("tag", uuid=new_tag_uuid),
 | 
			
		||||
        data=json.dumps({"title": "Updated Tag"}),
 | 
			
		||||
        headers={'content-type': 'application/json', 'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert b'OK' in res.data
 | 
			
		||||
 | 
			
		||||
    # Verify update worked
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tag", uuid=new_tag_uuid),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert res.json['title'] == 'Updated Tag'
 | 
			
		||||
 | 
			
		||||
    # Mute tag notifications
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tag", uuid=new_tag_uuid) + "?muted=muted",
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert b'OK' in res.data
 | 
			
		||||
 | 
			
		||||
    # Verify muted status
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tag", uuid=new_tag_uuid),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert res.json['notification_muted'] == True
 | 
			
		||||
 | 
			
		||||
    # Unmute tag
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tag", uuid=new_tag_uuid) + "?muted=unmuted",
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert b'OK' in res.data
 | 
			
		||||
 | 
			
		||||
    # Verify unmuted status
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tag", uuid=new_tag_uuid),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert res.json['notification_muted'] == False
 | 
			
		||||
 | 
			
		||||
    # Create a watch with the tag and check it matches UUID
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("createwatch"),
 | 
			
		||||
        data=json.dumps({"url": test_url, "tag": "Updated Tag", "title": "Watch with tag"}),
 | 
			
		||||
        headers={'content-type': 'application/json', 'x-api-key': api_key},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 201
 | 
			
		||||
    watch_uuid = res.json.get('uuid')
 | 
			
		||||
 | 
			
		||||
    # Verify tag is associated with watch by name if need be
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("watch", uuid=watch_uuid),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert new_tag_uuid in res.json.get('tags', [])
 | 
			
		||||
 | 
			
		||||
    # Delete tag
 | 
			
		||||
    res = client.delete(
 | 
			
		||||
        url_for("tag", uuid=new_tag_uuid),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 204
 | 
			
		||||
 | 
			
		||||
    # Verify tag is gone
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("tags"),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert new_tag_uuid not in res.text
 | 
			
		||||
 | 
			
		||||
    # Verify tag was removed from watch
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("watch", uuid=watch_uuid),
 | 
			
		||||
        headers={'x-api-key': api_key}
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
    assert new_tag_uuid not in res.json.get('tags', [])
 | 
			
		||||
 | 
			
		||||
    # Delete the watch
 | 
			
		||||
    res = client.delete(
 | 
			
		||||
        url_for("watch", uuid=watch_uuid),
 | 
			
		||||
        headers={'x-api-key': api_key},
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 204
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup, extract_UUID_from_client, extract_api_key_from_UI, wait_for_all_checks
 | 
			
		||||
from .util import live_server_setup, extract_UUID_from_client, wait_for_all_checks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_response_with_ldjson():
 | 
			
		||||
@@ -95,7 +95,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Should get a notice that it's available
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'ldjson-price-track-offer' in res.data
 | 
			
		||||
 | 
			
		||||
    # Accept it
 | 
			
		||||
@@ -105,12 +105,12 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    # Offer should be gone
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'Embedded price data' not in res.data
 | 
			
		||||
    assert b'tracking-ldjson-price-data' in res.data
 | 
			
		||||
 | 
			
		||||
    # and last snapshop (via API) should be just the price
 | 
			
		||||
    api_key = extract_api_key_from_UI(client)
 | 
			
		||||
    api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("watchsinglehistory", uuid=uuid, timestamp='latest'),
 | 
			
		||||
        headers={'x-api-key': api_key},
 | 
			
		||||
@@ -136,7 +136,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'ldjson-price-track-offer' not in res.data
 | 
			
		||||
    
 | 
			
		||||
    ##########################################################################################
 | 
			
		||||
 
 | 
			
		||||
@@ -39,7 +39,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
        # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        res = client.get(url_for("watchlist.index"))
 | 
			
		||||
        assert b'unviewed' not in res.data
 | 
			
		||||
        assert b'test-endpoint' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -75,7 +75,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    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"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # #75, and it should be in the RSS feed
 | 
			
		||||
@@ -112,7 +112,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
        # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        res = client.get(url_for("watchlist.index"))
 | 
			
		||||
        assert b'unviewed' not in res.data
 | 
			
		||||
        assert b'Mark all viewed' not in res.data
 | 
			
		||||
        assert b'head title' not in res.data  # Should not be present because this is off by default
 | 
			
		||||
@@ -131,7 +131,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
    assert b'Mark all viewed' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -151,7 +151,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    client.get(url_for("ui.clear_watch_history", uuid=uuid))
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'preview/' in res.data
 | 
			
		||||
 | 
			
		||||
    #
 | 
			
		||||
 
 | 
			
		||||
@@ -107,7 +107,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -120,7 +120,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -129,7 +129,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
 | 
			
		||||
    set_original_ignore_response()
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -137,7 +137,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
 | 
			
		||||
    set_modified_response_minus_block_text()
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,29 +2,39 @@
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from . util import live_server_setup
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_trigger_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
def test_clone_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        f.write("<html><body>Some content</body></html>")
 | 
			
		||||
 | 
			
		||||
    # Give the endpoint time to spin up
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("imports.import_page"),
 | 
			
		||||
        data={"urls": "https://changedetection.io"},
 | 
			
		||||
        data={"urls": test_url},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # So that we can be sure the same history doesnt carry over
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("ui.form_clone", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    existing_uuids = set()
 | 
			
		||||
 | 
			
		||||
    assert b"Cloned." in res.data
 | 
			
		||||
    for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items():
 | 
			
		||||
        new_uuids = set(watch.history.keys())
 | 
			
		||||
        duplicates = existing_uuids.intersection(new_uuids)
 | 
			
		||||
        assert len(duplicates) == 0
 | 
			
		||||
        existing_uuids.update(new_uuids)
 | 
			
		||||
 | 
			
		||||
    assert b"Cloned" in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
import json
 | 
			
		||||
import urllib
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from .util import live_server_setup, wait_for_all_checks
 | 
			
		||||
@@ -113,8 +113,9 @@ def test_conditions_with_text_and_number(client, live_server):
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    time.sleep(2)
 | 
			
		||||
    # 75 is > 20 and < 100 and contains "5"
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -128,7 +129,7 @@ def test_conditions_with_text_and_number(client, live_server):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Should NOT be marked as having changes since not all conditions are met
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
 
 | 
			
		||||
@@ -119,7 +119,7 @@ def test_check_markup_include_filters_restriction(client, live_server, measure_m
 | 
			
		||||
 | 
			
		||||
    # It should have 'unviewed' still
 | 
			
		||||
    # Because it should be looking at only that 'sametext' id
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -218,7 +218,7 @@ def test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("index"),
 | 
			
		||||
        url_for("watchlist.index"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@@ -240,7 +240,7 @@ def test_filter_is_empty_help_suggestion(client, live_server, measure_memory_usa
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("index"),
 | 
			
		||||
        url_for("watchlist.index"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -204,7 +204,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # There should not be an unviewed change, as changes should be removed
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b"unviewed" not in res.data
 | 
			
		||||
 | 
			
		||||
# Re #2752
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ def _runner_test_http_errors(client, live_server, http_code, expected_text):
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    # no change
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert bytes(expected_text.encode('utf-8')) in res.data
 | 
			
		||||
@@ -78,7 +78,7 @@ def test_DNS_errors(client, live_server, measure_memory_usage):
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    found_name_resolution_error = b"Temporary failure in name resolution" in res.data or b"Name or service not known" in res.data
 | 
			
		||||
    assert found_name_resolution_error
 | 
			
		||||
    # Should always record that we tried
 | 
			
		||||
@@ -107,7 +107,7 @@ def test_low_level_errors_clear_correctly(client, live_server, measure_memory_us
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # We should see the DNS error
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    found_name_resolution_error = b"Temporary failure in name resolution" in res.data or b"Name or service not known" in res.data
 | 
			
		||||
    assert found_name_resolution_error
 | 
			
		||||
 | 
			
		||||
@@ -122,7 +122,7 @@ def test_low_level_errors_clear_correctly(client, live_server, measure_memory_us
 | 
			
		||||
 | 
			
		||||
    # Now the error should be gone
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    found_name_resolution_error = b"Temporary failure in name resolution" in res.data or b"Name or service not known" in res.data
 | 
			
		||||
    assert not found_name_resolution_error
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -103,7 +103,7 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
    # Issue 1828
 | 
			
		||||
    assert b'not at the start of the expression' not in res.data
 | 
			
		||||
@@ -160,7 +160,7 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    #issue 1828
 | 
			
		||||
    assert b'not at the start of the expression' not in res.data
 | 
			
		||||
 | 
			
		||||
@@ -174,7 +174,7 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag
 | 
			
		||||
 | 
			
		||||
    # It should have 'unviewed' still
 | 
			
		||||
    # Because it should be looking at only that 'sametext' id
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # Check HTML conversion detected and workd
 | 
			
		||||
 
 | 
			
		||||
@@ -113,7 +113,7 @@ def run_filter_test(client, live_server, content_filter):
 | 
			
		||||
        checked += 1
 | 
			
		||||
        client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        res = client.get(url_for("watchlist.index"))
 | 
			
		||||
        assert b'Warning, no filters were found' in res.data
 | 
			
		||||
        assert not os.path.isfile("test-datastore/notification.txt")
 | 
			
		||||
        time.sleep(1)
 | 
			
		||||
 
 | 
			
		||||
@@ -77,7 +77,7 @@ def test_setup_group_tag(client, live_server, measure_memory_usage):
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'import-tag' in res.data
 | 
			
		||||
    assert b'extra-import-tag' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -90,7 +90,7 @@ def test_setup_group_tag(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'Warning, no filters were found' not in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
@@ -255,7 +255,7 @@ def test_limit_tag_ui(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    assert b"40 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'test-tag' in res.data
 | 
			
		||||
 | 
			
		||||
    # All should be here
 | 
			
		||||
@@ -263,7 +263,7 @@ def test_limit_tag_ui(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    tag_uuid = get_UUID_for_tag_name(client, name="test-tag")
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index", tag=tag_uuid))
 | 
			
		||||
    res = client.get(url_for("watchlist.index", tag=tag_uuid))
 | 
			
		||||
 | 
			
		||||
    # Just a subset should be here
 | 
			
		||||
    assert b'test-tag' in res.data
 | 
			
		||||
@@ -273,6 +273,7 @@ def test_limit_tag_ui(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
    res = client.get(url_for("tags.delete_all"), follow_redirects=True)
 | 
			
		||||
    assert b'All tags deleted' in res.data
 | 
			
		||||
 | 
			
		||||
def test_clone_tag_on_import(client, live_server, measure_memory_usage):
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
@@ -284,7 +285,7 @@ def test_clone_tag_on_import(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'test-tag' in res.data
 | 
			
		||||
    assert b'another-tag' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -292,6 +293,7 @@ def test_clone_tag_on_import(client, live_server, measure_memory_usage):
 | 
			
		||||
    res = client.get(url_for("ui.form_clone", uuid=watch_uuid), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
    assert b'Cloned' in res.data
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    # 2 times plus the top link to tag
 | 
			
		||||
    assert res.data.count(b'test-tag') == 3
 | 
			
		||||
    assert res.data.count(b'another-tag') == 3
 | 
			
		||||
@@ -311,14 +313,15 @@ def test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usa
 | 
			
		||||
 | 
			
		||||
    assert b"Watch added" in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'test-tag' in res.data
 | 
			
		||||
    assert b'another-tag' in res.data
 | 
			
		||||
 | 
			
		||||
    watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    res = client.get(url_for("ui.form_clone", uuid=watch_uuid), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
    assert b'Cloned' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    # 2 times plus the top link to tag
 | 
			
		||||
    assert res.data.count(b'test-tag') == 3
 | 
			
		||||
    assert res.data.count(b'another-tag') == 3
 | 
			
		||||
 
 | 
			
		||||
@@ -127,7 +127,7 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -140,7 +140,7 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -151,7 +151,7 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.ui_views.preview_page", uuid="first"))
 | 
			
		||||
@@ -214,7 +214,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class), adding random ignore text should not cause a change
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
#####
 | 
			
		||||
@@ -229,7 +229,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
@@ -238,7 +238,7 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
 | 
			
		||||
    set_modified_original_ignore_response()
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
 
 | 
			
		||||
@@ -114,7 +114,7 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag
 | 
			
		||||
 | 
			
		||||
    # since the link has changed, and we chose to render anchor tag content,
 | 
			
		||||
    # we should detect a change (new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b"unviewed" in res.data
 | 
			
		||||
    assert b"/test-endpoint" in res.data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -79,7 +79,7 @@ def test_normal_page_check_works_with_ignore_status_code(client, live_server, me
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -127,6 +127,6 @@ def test_403_page_check_works_with_ignore_status_code(client, live_server, measu
 | 
			
		||||
 | 
			
		||||
    # It should have 'unviewed' still
 | 
			
		||||
    # Because it should be looking at only that 'sametext' id
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -91,6 +91,6 @@ def test_check_ignore_whitespace(client, live_server, measure_memory_usage):
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -31,8 +31,8 @@ https://example.com tag1, other tag"""
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
    # Clear flask alerts
 | 
			
		||||
    res = client.get( url_for("index"))
 | 
			
		||||
    res = client.get( url_for("index"))
 | 
			
		||||
    res = client.get( url_for("watchlist.index"))
 | 
			
		||||
    res = client.get( url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
def xtest_import_skip_url(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
@@ -55,7 +55,7 @@ def xtest_import_skip_url(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"1 Skipped" in res.data
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    # Clear flask alerts
 | 
			
		||||
    res = client.get( url_for("index"))
 | 
			
		||||
    res = client.get( url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
def test_import_distillio(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
@@ -113,7 +113,7 @@ def test_import_distillio(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"xpath:(//div[@id='App']/div[contains(@class,'flex')]/main[contains(@class,'relative')]/section[contains(@class,'relative')]/div[@class='container']/div[contains(@class,'flex')]/div[contains(@class,'w-full')])[1]" in res.data
 | 
			
		||||
 | 
			
		||||
    # did the tags work?
 | 
			
		||||
    res = client.get( url_for("index"))
 | 
			
		||||
    res = client.get( url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
    # check tags
 | 
			
		||||
    assert b"nice stuff" in res.data
 | 
			
		||||
@@ -121,7 +121,7 @@ def test_import_distillio(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    # Clear flask alerts
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
def test_import_custom_xlsx(client, live_server, measure_memory_usage):
 | 
			
		||||
    """Test can upload a excel spreadsheet and the watches are created correctly"""
 | 
			
		||||
@@ -156,7 +156,7 @@ def test_import_custom_xlsx(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b'Error processing row number 1' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("index")
 | 
			
		||||
        url_for("watchlist.index")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b'Somesite results ABC' in res.data
 | 
			
		||||
@@ -194,7 +194,7 @@ def test_import_watchete_xlsx(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b'4 imported from Wachete .xlsx' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("index")
 | 
			
		||||
        url_for("watchlist.index")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b'Somesite results ABC' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -52,7 +52,7 @@ def test_jinja2_security_url_query(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'is invalid and cannot be used' in res.data
 | 
			
		||||
    # Some of the spewed output from the subclasses
 | 
			
		||||
    assert b'dict_values' not in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -281,7 +281,7 @@ def check_json_filter(json_filter, client, live_server):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should have 'unviewed' still
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # Should not see this, because its not in the JSONPath we entered
 | 
			
		||||
@@ -417,7 +417,7 @@ def check_json_ext_filter(json_filter, client, live_server):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should have 'unviewed'
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
 | 
			
		||||
@@ -455,7 +455,7 @@ def test_ignore_json_order(client, live_server, measure_memory_usage):
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # Just to be sure it still works
 | 
			
		||||
@@ -466,7 +466,7 @@ def test_ignore_json_order(client, live_server, measure_memory_usage):
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
@@ -488,7 +488,7 @@ def test_correct_header_detect(client, live_server, measure_memory_usage):
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
    # Fixed in #1593
 | 
			
		||||
    assert b'No parsable JSON found in this document' not in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -41,7 +41,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -63,7 +63,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
@@ -93,7 +93,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
    client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
@@ -105,7 +105,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
 | 
			
		||||
    assert watch.last_changed == watch['last_checked']
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data # A change should have registered because empty_pages_are_a_change is ON
 | 
			
		||||
    assert b'fetch-error' not in res.data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -139,7 +139,7 @@ def test_check_notification(client, live_server, measure_memory_usage):
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
 | 
			
		||||
    # Check no errors were recorded
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'notification-error' not in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -46,7 +46,7 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u
 | 
			
		||||
 | 
			
		||||
        logging.debug("Fetching watch overview....")
 | 
			
		||||
        res = client.get(
 | 
			
		||||
            url_for("index"))
 | 
			
		||||
            url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
        if bytes("Notification error detected".encode('utf-8')) in res.data:
 | 
			
		||||
            found=True
 | 
			
		||||
 
 | 
			
		||||
@@ -50,7 +50,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Now something should be ready, indicated by having a 'unviewed' class
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # The original checksum should be not be here anymore (cdio adds it to the bottom of the text)
 | 
			
		||||
 
 | 
			
		||||
@@ -48,7 +48,7 @@ def test_fetch_pdf(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Now something should be ready, indicated by having a 'unviewed' class
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # The original checksum should be not be here anymore (cdio adds it to the bottom of the text)
 | 
			
		||||
 
 | 
			
		||||
@@ -64,7 +64,7 @@ def test_restock_itemprop_basic(client, live_server):
 | 
			
		||||
            follow_redirects=True
 | 
			
		||||
        )
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        res = client.get(url_for("watchlist.index"))
 | 
			
		||||
        assert b'more than one price detected' not in res.data
 | 
			
		||||
        assert b'has-restock-info' in res.data
 | 
			
		||||
        assert b' in-stock' in res.data
 | 
			
		||||
@@ -81,7 +81,7 @@ def test_restock_itemprop_basic(client, live_server):
 | 
			
		||||
            follow_redirects=True
 | 
			
		||||
        )
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        res = client.get(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
        assert b'has-restock-info not-in-stock' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -103,14 +103,14 @@ def test_itemprop_price_change(client, live_server):
 | 
			
		||||
 | 
			
		||||
    # A change in price, should trigger a change by default
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'190.95' in res.data
 | 
			
		||||
 | 
			
		||||
    # basic price change, look for notification
 | 
			
		||||
    set_original_response(props_markup=instock_props[0], price='180.45')
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'180.45' in res.data
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
    client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
 | 
			
		||||
@@ -125,7 +125,7 @@ def test_itemprop_price_change(client, live_server):
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'120.45' in res.data
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
@@ -170,7 +170,7 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
 | 
			
		||||
    set_original_response(props_markup=instock_props[0], price='1000.45')
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"))
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
    assert b'more than one price detected' not in res.data
 | 
			
		||||
    # BUT the new price should show, even tho its within limits
 | 
			
		||||
@@ -183,7 +183,7 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
 | 
			
		||||
    res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'Queued 1 watch for rechecking.' in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'890.45' in res.data
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -195,7 +195,7 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
 | 
			
		||||
    res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'Queued 1 watch for rechecking.' in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'820.45' in res.data
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
    client.get(url_for("ui.mark_all_viewed"))
 | 
			
		||||
@@ -204,7 +204,7 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
 | 
			
		||||
    set_original_response(props_markup=instock_props[0], price='1890.45')
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    # Depending on the LOCALE it may be either of these (generally for US/default/etc)
 | 
			
		||||
    assert b'1,890.45' in res.data or b'1890.45' in res.data
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
@@ -288,7 +288,7 @@ def test_itemprop_percent_threshold(client, live_server):
 | 
			
		||||
    set_original_response(props_markup=instock_props[0], price='960.45')
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"))
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'960.45' in res.data
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
@@ -296,7 +296,7 @@ def test_itemprop_percent_threshold(client, live_server):
 | 
			
		||||
    set_original_response(props_markup=instock_props[0], price='1960.45')
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"))
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'1,960.45' or b'1960.45' in res.data #depending on locale
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -306,7 +306,7 @@ def test_itemprop_percent_threshold(client, live_server):
 | 
			
		||||
    set_original_response(props_markup=instock_props[0], price='1950.45')
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"))
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'1,950.45' or b'1950.45' in res.data #depending on locale
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
@@ -403,7 +403,7 @@ def test_data_sanity(client, live_server):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'950.95' in res.data
 | 
			
		||||
 | 
			
		||||
    # Check the restock model object doesnt store the value by mistake and used in a new one
 | 
			
		||||
@@ -413,7 +413,7 @@ def test_data_sanity(client, live_server):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert str(res.data.decode()).count("950.95") == 1, "Price should only show once (for the watch added, no other watches yet)"
 | 
			
		||||
 | 
			
		||||
    ## different test, check the edit page works on an empty request result
 | 
			
		||||
@@ -455,6 +455,6 @@ def test_special_prop_examples(client, live_server):
 | 
			
		||||
                follow_redirects=True
 | 
			
		||||
            )
 | 
			
		||||
            wait_for_all_checks(client)
 | 
			
		||||
            res = client.get(url_for("index"))
 | 
			
		||||
            res = client.get(url_for("watchlist.index"))
 | 
			
		||||
            assert b'ception' not in res.data
 | 
			
		||||
            assert b'155.55' in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -49,6 +49,22 @@ def set_original_cdata_xml():
 | 
			
		||||
        f.write(test_return_data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_html_content(content):
 | 
			
		||||
    test_return_data = f"""<html>
 | 
			
		||||
       <body>
 | 
			
		||||
     Some initial text<br>
 | 
			
		||||
     <p>{content}</p>
 | 
			
		||||
     <br>
 | 
			
		||||
     So let's see what happens.  <br>
 | 
			
		||||
     </body>
 | 
			
		||||
     </html>
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    # Write as UTF-8 encoded bytes
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "wb") as f:
 | 
			
		||||
        f.write(test_return_data.encode('utf-8'))
 | 
			
		||||
 | 
			
		||||
def test_setup(client, live_server, measure_memory_usage):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
@@ -164,3 +180,58 @@ def test_rss_xpath_filtering(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b'Some other description' not in res.data  # Should NOT be selected by the xpath
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_rss_bad_chars_breaking(client, live_server):
 | 
			
		||||
    """This should absolutely trigger the RSS builder to go into worst state mode
 | 
			
		||||
 | 
			
		||||
    - source: prefix means no html conversion (which kinda filters out the bad stuff)
 | 
			
		||||
    - Binary data
 | 
			
		||||
    - Very long so that the saving is performed by Brotli (and decoded back to bytes)
 | 
			
		||||
 | 
			
		||||
    Otherwise feedgen should support regular unicode
 | 
			
		||||
    """
 | 
			
		||||
    #live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        ten_kb_string = "A" * 10_000
 | 
			
		||||
        f.write(ten_kb_string)
 | 
			
		||||
 | 
			
		||||
    test_url = url_for('test_endpoint', _external=True)
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("imports.import_page"),
 | 
			
		||||
        data={"urls": "source:"+test_url},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Set the bad content
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
        jpeg_bytes = "\xff\xd8\xff\xe0\x00\x10XXXXXXXX\x00\x01\x02\x00\x00\x01\x00\x01\x00\x00"  # JPEG header
 | 
			
		||||
        jpeg_bytes += "A" * 10_000
 | 
			
		||||
 | 
			
		||||
        f.write(jpeg_bytes)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    assert b'Queued 1 watch for rechecking.' in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    rss_token = extract_rss_token_from_UI(client)
 | 
			
		||||
 | 
			
		||||
    uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
 | 
			
		||||
    assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2
 | 
			
		||||
 | 
			
		||||
    # Check RSS feed is still working
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("rss.feed", uuid=uuid, token=rss_token),
 | 
			
		||||
        follow_redirects=False # Important! leave this off! it should not redirect
 | 
			
		||||
    )
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
 | 
			
		||||
    #assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2
 | 
			
		||||
    #assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@ def test_basic_search(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert b"2 Imported" in res.data
 | 
			
		||||
 | 
			
		||||
    # By URL
 | 
			
		||||
    res = client.get(url_for("index") + "?q=first-res")
 | 
			
		||||
    res = client.get(url_for("watchlist.index") + "?q=first-res")
 | 
			
		||||
    assert urls[0].encode('utf-8') in res.data
 | 
			
		||||
    assert urls[1].encode('utf-8') not in res.data
 | 
			
		||||
 | 
			
		||||
@@ -33,7 +33,7 @@ def test_basic_search(client, live_server, measure_memory_usage):
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index") + "?q=xxx-title")
 | 
			
		||||
    res = client.get(url_for("watchlist.index") + "?q=xxx-title")
 | 
			
		||||
    assert urls[0].encode('utf-8') in res.data
 | 
			
		||||
    assert urls[1].encode('utf-8') not in res.data
 | 
			
		||||
 | 
			
		||||
@@ -54,7 +54,7 @@ def test_search_in_tag_limit(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    # By URL
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index") + "?q=first-res")
 | 
			
		||||
    res = client.get(url_for("watchlist.index") + "?q=first-res")
 | 
			
		||||
    # Split because of the import tag separation
 | 
			
		||||
    assert urls[0].split(' ')[0].encode('utf-8') in res.data, urls[0].encode('utf-8')
 | 
			
		||||
    assert urls[1].split(' ')[0].encode('utf-8') not in res.data, urls[0].encode('utf-8')
 | 
			
		||||
@@ -68,7 +68,7 @@ def test_search_in_tag_limit(client, live_server, measure_memory_usage):
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index") + "?q=xxx-title")
 | 
			
		||||
    res = client.get(url_for("watchlist.index") + "?q=xxx-title")
 | 
			
		||||
    assert urls[0].split(' ')[0].encode('utf-8') in res.data, urls[0].encode('utf-8')
 | 
			
		||||
    assert urls[1].split(' ')[0].encode('utf-8') not in res.data, urls[0].encode('utf-8')
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -67,7 +67,7 @@ def _runner_test_various_file_slash(client, file_uri):
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
 | 
			
		||||
    substrings = [b"URLs with hostname components are not permitted", b"No connection adapters were found for"]
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -76,5 +76,5 @@ def test_share_watch(client, live_server, measure_memory_usage):
 | 
			
		||||
    assert bytes(include_filters.encode('utf-8')) in res.data
 | 
			
		||||
 | 
			
		||||
    # Check it saved the URL
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert bytes(test_url.encode('utf-8')) in res.data
 | 
			
		||||
 
 | 
			
		||||
@@ -45,7 +45,7 @@ def test_check_basic_change_detection_functionality_source(client, live_server,
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # Now something should be ready, indicated by having a 'unviewed' class
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
 
 | 
			
		||||
@@ -104,7 +104,7 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    assert b'/test-endpoint' in res.data
 | 
			
		||||
 | 
			
		||||
@@ -116,7 +116,7 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # Now set the content which contains the trigger text
 | 
			
		||||
@@ -124,7 +124,7 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
    
 | 
			
		||||
    # https://github.com/dgtlmoon/changedetection.io/issues/616
 | 
			
		||||
 
 | 
			
		||||
@@ -41,7 +41,7 @@ def test_trigger_regex_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (just a new one shouldnt have anything)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    ### test regex
 | 
			
		||||
@@ -63,7 +63,7 @@ def test_trigger_regex_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (nothing should match the regex)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    with open("test-datastore/endpoint-content.txt", "w") as f:
 | 
			
		||||
@@ -71,7 +71,7 @@ def test_trigger_regex_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # Cleanup everything
 | 
			
		||||
 
 | 
			
		||||
@@ -67,7 +67,7 @@ def test_trigger_regex_functionality_with_filter(client, live_server, measure_me
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (nothing should match the regex and filter)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # now this should trigger something
 | 
			
		||||
@@ -76,7 +76,7 @@ def test_trigger_regex_functionality_with_filter(client, live_server, measure_me
 | 
			
		||||
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
# Cleanup everything
 | 
			
		||||
 
 | 
			
		||||
@@ -108,14 +108,14 @@ def test_unique_lines_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    # It should report nothing found (no new 'unviewed' class)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # Now set the content which contains the new text and re-ordered existing text
 | 
			
		||||
    set_modified_with_trigger_text_response()
 | 
			
		||||
    client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
@@ -153,7 +153,7 @@ def test_sort_lines_functionality(client, live_server, measure_memory_usage):
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    # Should be a change registered
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -98,7 +98,7 @@ def test_check_xpath_filter_utf8(client, live_server, measure_memory_usage):
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'Unicode strings with encoding declaration are not supported.' not in res.data
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
@@ -152,7 +152,7 @@ def test_check_xpath_text_function_utf8(client, live_server, measure_memory_usag
 | 
			
		||||
    )
 | 
			
		||||
    assert b"Updated watch." in res.data
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'Unicode strings with encoding declaration are not supported.' not in res.data
 | 
			
		||||
 | 
			
		||||
    # The service should echo back the request headers
 | 
			
		||||
@@ -208,7 +208,7 @@ def test_check_markup_xpath_filter_restriction(client, live_server, measure_memo
 | 
			
		||||
    # Give the thread time to pick it up
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
    assert b'Deleted' in res.data
 | 
			
		||||
@@ -305,7 +305,7 @@ def test_xpath1_lxml(client, live_server, measure_memory_usage):
 | 
			
		||||
 | 
			
		||||
    ##### #2312
 | 
			
		||||
    wait_for_all_checks(client)
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    assert b'_ElementStringResult' not in res.data # tested with 5.1.1 when it was removed and 5.1.0
 | 
			
		||||
    assert b'Exception' not in res.data
 | 
			
		||||
    res = client.get(
 | 
			
		||||
@@ -419,7 +419,7 @@ def test_various_rules(client, live_server, measure_memory_usage):
 | 
			
		||||
        )
 | 
			
		||||
        wait_for_all_checks(client)
 | 
			
		||||
        assert b"Updated watch." in res.data
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        res = client.get(url_for("watchlist.index"))
 | 
			
		||||
        assert b'fetch-error' not in res.data, f"Should not see errors after '{r} filter"
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
 | 
			
		||||
 
 | 
			
		||||
@@ -75,7 +75,7 @@ class TestTriggerConditions(unittest.TestCase):
 | 
			
		||||
                                                     ephemeral_data={'text': "I saw 500 people at a rock show"})
 | 
			
		||||
 | 
			
		||||
        # @todo - now we can test that 'Extract number' increased more than X since last time
 | 
			
		||||
        self.assertTrue(result)
 | 
			
		||||
        self.assertTrue(result.get('result'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
 
 | 
			
		||||
@@ -95,20 +95,6 @@ def wait_for_notification_endpoint_output():
 | 
			
		||||
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# kinda funky, but works for now
 | 
			
		||||
def extract_api_key_from_UI(client):
 | 
			
		||||
    import re
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("settings.settings_page"),
 | 
			
		||||
    )
 | 
			
		||||
    # <span id="api-key">{{api_key}}</span>
 | 
			
		||||
 | 
			
		||||
    m = re.search('<span id="api-key">(.+?)</span>', str(res.data))
 | 
			
		||||
    api_key = m.group(1)
 | 
			
		||||
    return api_key.strip()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# kinda funky, but works for now
 | 
			
		||||
def get_UUID_for_tag_name(client, name):
 | 
			
		||||
    app_config = client.application.config.get('DATASTORE').data
 | 
			
		||||
@@ -122,7 +108,7 @@ def get_UUID_for_tag_name(client, name):
 | 
			
		||||
def extract_rss_token_from_UI(client):
 | 
			
		||||
    import re
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("index"),
 | 
			
		||||
        url_for("watchlist.index"),
 | 
			
		||||
    )
 | 
			
		||||
    m = re.search('token=(.+?)"', str(res.data))
 | 
			
		||||
    token_key = m.group(1)
 | 
			
		||||
@@ -132,7 +118,7 @@ def extract_rss_token_from_UI(client):
 | 
			
		||||
def extract_UUID_from_client(client):
 | 
			
		||||
    import re
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("index"),
 | 
			
		||||
        url_for("watchlist.index"),
 | 
			
		||||
    )
 | 
			
		||||
    # <span id="api-key">{{api_key}}</span>
 | 
			
		||||
 | 
			
		||||
@@ -147,7 +133,7 @@ def wait_for_all_checks(client):
 | 
			
		||||
    # because sub-second rechecks are problematic in testing, use lots of delays
 | 
			
		||||
    time.sleep(1)
 | 
			
		||||
    while attempt < 60:
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        res = client.get(url_for("watchlist.index"))
 | 
			
		||||
        if not b'Checking now' in res.data:
 | 
			
		||||
            break
 | 
			
		||||
        logging.getLogger().info("Waiting for watch-list to not say 'Checking now'.. {}".format(attempt))
 | 
			
		||||
@@ -187,7 +173,7 @@ def live_server_setup(live_server):
 | 
			
		||||
                return resp
 | 
			
		||||
 | 
			
		||||
            # Tried using a global var here but didn't seem to work, so reading from a file instead.
 | 
			
		||||
            with open("test-datastore/endpoint-content.txt", "r") as f:
 | 
			
		||||
            with open("test-datastore/endpoint-content.txt", "rb") as f:
 | 
			
		||||
                resp = make_response(f.read(), status_code)
 | 
			
		||||
                if uppercase_headers:
 | 
			
		||||
                    resp.headers['CONTENT-TYPE'] = ctype if ctype else 'text/html'
 | 
			
		||||
@@ -320,7 +306,7 @@ def get_index(client):
 | 
			
		||||
 | 
			
		||||
    print(f"Called by: {caller_name}, Line: {caller_line}")
 | 
			
		||||
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    res = client.get(url_for("watchlist.index"))
 | 
			
		||||
    with open(f"test-datastore/index-{caller_name}-{caller_line}.html", 'wb') as f:
 | 
			
		||||
        f.write(res.data)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -253,8 +253,9 @@ class update_worker(threading.Thread):
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                fetch_start_time = time.time()
 | 
			
		||||
 | 
			
		||||
                uuid = queued_item_data.item.get('uuid')
 | 
			
		||||
                fetch_start_time = round(time.time())  # Also used for a unique history key for now
 | 
			
		||||
                self.current_uuid = uuid
 | 
			
		||||
                if uuid in list(self.datastore.data['watching'].keys()) and self.datastore.data['watching'][uuid].get('url'):
 | 
			
		||||
                    changed_detected = False
 | 
			
		||||
@@ -262,8 +263,10 @@ class update_worker(threading.Thread):
 | 
			
		||||
                    process_changedetection_results = True
 | 
			
		||||
                    update_obj = {}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                    # Clear last errors (move to preflight func?)
 | 
			
		||||
                    self.datastore.data['watching'][uuid]['browser_steps_last_error_step'] = None
 | 
			
		||||
                    self.datastore.data['watching'][uuid]['last_checked'] = fetch_start_time
 | 
			
		||||
 | 
			
		||||
                    watch = self.datastore.data['watching'].get(uuid)
 | 
			
		||||
 | 
			
		||||
@@ -287,10 +290,6 @@ class update_worker(threading.Thread):
 | 
			
		||||
 | 
			
		||||
                        update_handler.call_browser()
 | 
			
		||||
 | 
			
		||||
                        # In reality, the actual time of when the change was detected could be a few seconds after this
 | 
			
		||||
                        # For example it should include when the page stopped rendering if using a playwright/chrome type fetch
 | 
			
		||||
                        fetch_start_time = time.time()
 | 
			
		||||
 | 
			
		||||
                        changed_detected, update_obj, contents = update_handler.run_changedetection(watch=watch)
 | 
			
		||||
 | 
			
		||||
                        # Re #342
 | 
			
		||||
@@ -587,7 +586,6 @@ class update_worker(threading.Thread):
 | 
			
		||||
                        pass
 | 
			
		||||
 | 
			
		||||
                    self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
 | 
			
		||||
                                                                       'last_checked': int(fetch_start_time),
 | 
			
		||||
                                                                       'check_count': count
 | 
			
		||||
                                                                       })
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,3 @@
 | 
			
		||||
# Used by Pyppeteer
 | 
			
		||||
pyee
 | 
			
		||||
 | 
			
		||||
eventlet>=0.38.0
 | 
			
		||||
feedgen~=0.9
 | 
			
		||||
flask-compress
 | 
			
		||||
@@ -73,7 +70,8 @@ jq~=1.3; python_version >= "3.8" and sys_platform == "linux"
 | 
			
		||||
 | 
			
		||||
# playwright is installed at Dockerfile build time because it's not available on all platforms
 | 
			
		||||
 | 
			
		||||
pyppeteer-ng==2.0.0rc5
 | 
			
		||||
pyppeteer-ng==2.0.0rc9
 | 
			
		||||
 | 
			
		||||
pyppeteerstealth>=0.0.4
 | 
			
		||||
 | 
			
		||||
# Include pytest, so if theres a support issue we can ask them to run these tests on their setup
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user