Compare commits

...

15 Commits

Author SHA1 Message Date
dgtlmoon
7b3d054a4a Tweak to tests 2025-03-27 10:24:41 +01:00
dgtlmoon
3d17a85c79 Clear title on save 2025-03-27 09:59:12 +01:00
dgtlmoon
694a8e2fe7 Re #2782 - Should be "Clone & Edit" without watch history 2025-03-27 09:52:22 +01:00
dgtlmoon
6c1b687cd1 UI - Tidy up support links 2025-03-27 09:10:36 +01:00
dgtlmoon
e850540a91 UI - Set a graph % of ETA time completed of checking the watch (#3060)
Some checks failed
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-03-26 17:06:24 +01:00
dgtlmoon
d4bc9dfc50 0.49.9 2025-03-26 16:30:08 +01:00
dgtlmoon
f26ea55e9c RSS Fixes and improvements - Ability to set "RSS Color HTML Format" in Settings, detect and filter content with bad content that could break RSS (#3055)
Some checks are pending
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
2025-03-26 12:08:15 +01:00
dgtlmoon
b53e1985ac 0.49.8 2025-03-25 22:59:56 +01:00
dgtlmoon
302ef80d95 Server - Path blueprint fixes and moving code blueprint to fix RSS forward slash on url (#3054)
Some checks are pending
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
2025-03-25 22:57:15 +01:00
dgtlmoon
5b97c29714 API - Adding "Search" API (#3052)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-03-24 12:49:28 +01:00
dgtlmoon
64075c87ee Fetching - Upgrading to pyppeteer-ng 2.0.0rc8 (more modern pyee requirements)
Some checks failed
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
ChangeDetection.io Container Build Test / test-container-build (push) Has been cancelled
2025-03-23 22:20:43 +01:00
dgtlmoon
d58a71cffc 0.49.7
Some checks are pending
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
2025-03-23 16:50:21 +01:00
dgtlmoon
036b006226 Adding Tags/Groups API (#3049) 2025-03-23 16:41:38 +01:00
dgtlmoon
f29f89d078 0.49.6
Some checks are pending
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
2025-03-23 02:01:32 +01:00
dgtlmoon
289f118581 API Access should still work even when UI Password is enabled (#3046) #3045 2025-03-23 02:00:05 +01:00
73 changed files with 1346 additions and 596 deletions

View File

@@ -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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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')]

View 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

View File

@@ -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.")

View File

@@ -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>

View File

@@ -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>&nbsp;
<a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a>

View File

@@ -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

View File

@@ -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():

View File

@@ -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>

View File

@@ -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

View 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

View File

@@ -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 &amp; 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 %}

View File

@@ -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,7 @@ 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 and request.view_args.get('group') in ['styles', 'js', 'images', 'favicons']:
return None
# Permitted
elif request.endpoint and 'login' in request.endpoint:
@@ -244,34 +247,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 +295,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 +310,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 +327,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,110 +346,8 @@ 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
@@ -524,12 +433,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()

View File

@@ -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
@@ -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()])

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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

View File

@@ -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 :)

View File

@@ -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 &amp; Edit</a>
</div>
</div>
</form>

View File

@@ -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(

View File

@@ -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"

View File

@@ -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,14 @@ 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
# Check wrong password does not let us in
res = c.post(
url_for("login"),
@@ -164,7 +172,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

View File

@@ -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

View File

@@ -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)

View 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]

View 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

View File

@@ -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
##########################################################################################

View File

@@ -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
#

View File

@@ -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

View File

@@ -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

View File

@@ -114,7 +114,7 @@ def test_conditions_with_text_and_number(client, live_server):
wait_for_all_checks(client)
# 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 +128,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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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=&#39;App&#39;]/div[contains(@class,&#39;flex&#39;)]/main[contains(@class,&#39;relative&#39;)]/section[contains(@class,&#39;relative&#39;)]/div[@class=&#39;container&#39;]/div[contains(@class,&#39;flex&#39;)]/div[contains(@class,&#39;w-full&#39;)])[1]" in res.data
# 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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"]

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
})

View File

@@ -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