mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-12 20:46:17 +00:00
Compare commits
49 Commits
3045-api-a
...
playwright
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d2bc5049b | ||
|
|
186016e605 | ||
|
|
3a583a4e5d | ||
|
|
cfb4decf67 | ||
|
|
8067d5170b | ||
|
|
5551acf67d | ||
|
|
45a030bac6 | ||
|
|
96dc49e229 | ||
|
|
5f43d988a3 | ||
|
|
4269079c54 | ||
|
|
cdfb3f206c | ||
|
|
9f326783e5 | ||
|
|
4e6e680d79 | ||
|
|
1378b5b2ff | ||
|
|
456c6e3f58 | ||
|
|
61be7f68db | ||
|
|
0e38a3c881 | ||
|
|
2c630e9853 | ||
|
|
786e0d1fab | ||
|
|
78b7aee512 | ||
|
|
9d9d01863a | ||
|
|
108cdf84a5 | ||
|
|
8c6f6f1578 | ||
|
|
df4ffaaff8 | ||
|
|
d522c65e50 | ||
|
|
c3b2a8b019 | ||
|
|
28d3151090 | ||
|
|
2a1c832f8d | ||
|
|
0170adb171 | ||
|
|
cb62404b8c | ||
|
|
8f9c46bd3f | ||
|
|
97291ce6d0 | ||
|
|
f689e5418e | ||
|
|
f751f0b0ef | ||
|
|
ea9ba3bb2e | ||
|
|
c7ffebce2a | ||
|
|
54b7c070f7 | ||
|
|
6c1b687cd1 | ||
|
|
e850540a91 | ||
|
|
d4bc9dfc50 | ||
|
|
f26ea55e9c | ||
|
|
b53e1985ac | ||
|
|
302ef80d95 | ||
|
|
5b97c29714 | ||
|
|
64075c87ee | ||
|
|
d58a71cffc | ||
|
|
036b006226 | ||
|
|
f29f89d078 | ||
|
|
289f118581 |
1
.github/workflows/test-only.yml
vendored
1
.github/workflows/test-only.yml
vendored
@@ -28,7 +28,6 @@ jobs:
|
|||||||
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
skip-pypuppeteer: true
|
|
||||||
|
|
||||||
test-application-3-12:
|
test-application-3-12:
|
||||||
needs: lint-code
|
needs: lint-code
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
description: 'Python version to use'
|
description: 'Python version to use'
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
default: '3.10'
|
default: '3.11'
|
||||||
skip-pypuppeteer:
|
skip-pypuppeteer:
|
||||||
description: 'Skip PyPuppeteer (not supported in 3.11/3.12)'
|
description: 'Skip PyPuppeteer (not supported in 3.11/3.12)'
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# pip dependencies install stage
|
# pip dependencies install stage
|
||||||
|
|
||||||
# @NOTE! I would love to move to 3.11 but it breaks the async handler in changedetectionio/content_fetchers/puppeteer.py
|
|
||||||
# If you know how to fix it, please do! and test it for both 3.10 and 3.11
|
|
||||||
|
|
||||||
ARG PYTHON_VERSION=3.11
|
ARG PYTHON_VERSION=3.11
|
||||||
|
|
||||||
FROM python:${PYTHON_VERSION}-slim-bookworm AS builder
|
FROM python:${PYTHON_VERSION}-slim-bookworm AS builder
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
recursive-include changedetectionio/api *
|
recursive-include changedetectionio/api *
|
||||||
recursive-include changedetectionio/apprise_plugin *
|
|
||||||
recursive-include changedetectionio/blueprint *
|
recursive-include changedetectionio/blueprint *
|
||||||
recursive-include changedetectionio/content_fetchers *
|
recursive-include changedetectionio/content_fetchers *
|
||||||
recursive-include changedetectionio/conditions *
|
recursive-include changedetectionio/conditions *
|
||||||
recursive-include changedetectionio/model *
|
recursive-include changedetectionio/model *
|
||||||
|
recursive-include changedetectionio/notification *
|
||||||
recursive-include changedetectionio/processors *
|
recursive-include changedetectionio/processors *
|
||||||
recursive-include changedetectionio/static *
|
recursive-include changedetectionio/static *
|
||||||
recursive-include changedetectionio/templates *
|
recursive-include changedetectionio/templates *
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ _Need an actual Chrome runner with Javascript support? We support fetching via W
|
|||||||
#### Key Features
|
#### Key Features
|
||||||
|
|
||||||
- Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions!
|
- Lots of trigger filters, such as "Trigger on text", "Remove text by selector", "Ignore text", "Extract text", also using regular-expressions!
|
||||||
- Target elements with xPath(1.0) and CSS Selectors, Easily monitor complex JSON with JSONPath or jq
|
- Target elements with xPath 1 and xPath 2, CSS Selectors, Easily monitor complex JSON with JSONPath or jq
|
||||||
- Switch between fast non-JS and Chrome JS based "fetchers"
|
- Switch between fast non-JS and Chrome JS based "fetchers"
|
||||||
- Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums)
|
- Track changes in PDF files (Monitor text changed in the PDF, Also monitor PDF filesize and checksums)
|
||||||
- Easily specify how often a site should be checked
|
- Easily specify how often a site should be checked
|
||||||
@@ -105,6 +105,12 @@ We [recommend and use Bright Data](https://brightdata.grsm.io/n0r16zf7eivq) glob
|
|||||||
|
|
||||||
Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/
|
Please :star: star :star: this project and help it grow! https://github.com/dgtlmoon/changedetection.io/
|
||||||
|
|
||||||
|
### Conditional web page changes
|
||||||
|
|
||||||
|
Easily [configure conditional actions](https://changedetection.io/tutorial/conditional-actions-web-page-changes), for example, only trigger when a price is above or below a preset amount, or [when a web page includes (or does not include) a keyword](https://changedetection.io/tutorial/how-monitor-keywords-any-website)
|
||||||
|
|
||||||
|
<img src="./docs/web-page-change-conditions.png" style="max-width:80%;" alt="Conditional web page changes" title="Conditional web page changes" />
|
||||||
|
|
||||||
### Schedule web page watches in any timezone, limit by day of week and time.
|
### Schedule web page watches in any timezone, limit by day of week and time.
|
||||||
|
|
||||||
Easily set a re-check schedule, for example you could limit the web page change detection to only operate during business hours.
|
Easily set a re-check schedule, for example you could limit the web page change detection to only operate during business hours.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||||
|
|
||||||
__version__ = '0.49.5'
|
__version__ = '0.49.13'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
@@ -11,6 +11,7 @@ os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
|
|||||||
import eventlet
|
import eventlet
|
||||||
import eventlet.wsgi
|
import eventlet.wsgi
|
||||||
import getopt
|
import getopt
|
||||||
|
import platform
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
@@ -19,7 +20,6 @@ from changedetectionio import store
|
|||||||
from changedetectionio.flask_app import changedetection_app
|
from changedetectionio.flask_app import changedetection_app
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
# Only global so we can access it in the signal handler
|
# Only global so we can access it in the signal handler
|
||||||
app = None
|
app = None
|
||||||
datastore = None
|
datastore = None
|
||||||
@@ -29,8 +29,6 @@ def get_version():
|
|||||||
|
|
||||||
# Parent wrapper or OS sends us a SIGTERM/SIGINT, do everything required for a clean shutdown
|
# Parent wrapper or OS sends us a SIGTERM/SIGINT, do everything required for a clean shutdown
|
||||||
def sigshutdown_handler(_signo, _stack_frame):
|
def sigshutdown_handler(_signo, _stack_frame):
|
||||||
global app
|
|
||||||
global datastore
|
|
||||||
name = signal.Signals(_signo).name
|
name = signal.Signals(_signo).name
|
||||||
logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown')
|
logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown')
|
||||||
datastore.sync_to_json()
|
datastore.sync_to_json()
|
||||||
@@ -148,6 +146,19 @@ def main():
|
|||||||
signal.signal(signal.SIGTERM, sigshutdown_handler)
|
signal.signal(signal.SIGTERM, sigshutdown_handler)
|
||||||
signal.signal(signal.SIGINT, sigshutdown_handler)
|
signal.signal(signal.SIGINT, sigshutdown_handler)
|
||||||
|
|
||||||
|
# Custom signal handler for memory cleanup
|
||||||
|
def sigusr_clean_handler(_signo, _stack_frame):
|
||||||
|
from changedetectionio.gc_cleanup import memory_cleanup
|
||||||
|
logger.info('SIGUSR1 received: Running memory cleanup')
|
||||||
|
return memory_cleanup(app)
|
||||||
|
|
||||||
|
# Register the SIGUSR1 signal handler
|
||||||
|
# Only register the signal handler if running on Linux
|
||||||
|
if platform.system() == "Linux":
|
||||||
|
signal.signal(signal.SIGUSR1, sigusr_clean_handler)
|
||||||
|
else:
|
||||||
|
logger.info("SIGUSR1 handler only registered on Linux, skipped.")
|
||||||
|
|
||||||
# Go into cleanup mode
|
# Go into cleanup mode
|
||||||
if do_cleanup:
|
if do_cleanup:
|
||||||
datastore.remove_unused_snapshots()
|
datastore.remove_unused_snapshots()
|
||||||
|
|||||||
62
changedetectionio/api/Import.py
Normal file
62
changedetectionio/api/Import.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import os
|
||||||
|
from changedetectionio.strtobool import strtobool
|
||||||
|
from flask_restful import abort, Resource
|
||||||
|
from flask import request
|
||||||
|
import validators
|
||||||
|
from . import auth
|
||||||
|
|
||||||
|
|
||||||
|
class Import(Resource):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
# datastore is a black box dependency
|
||||||
|
self.datastore = kwargs['datastore']
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
def post(self):
|
||||||
|
"""
|
||||||
|
@api {post} /api/v1/import Import a list of watched URLs
|
||||||
|
@apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line.
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a"
|
||||||
|
@apiName Import
|
||||||
|
@apiGroup Watch
|
||||||
|
@apiSuccess (200) {List} OK List of watch UUIDs added
|
||||||
|
@apiSuccess (500) {String} ERR Some other error
|
||||||
|
"""
|
||||||
|
|
||||||
|
extras = {}
|
||||||
|
|
||||||
|
if request.args.get('proxy'):
|
||||||
|
plist = self.datastore.proxy_list
|
||||||
|
if not request.args.get('proxy') in plist:
|
||||||
|
return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400
|
||||||
|
else:
|
||||||
|
extras['proxy'] = request.args.get('proxy')
|
||||||
|
|
||||||
|
dedupe = strtobool(request.args.get('dedupe', 'true'))
|
||||||
|
|
||||||
|
tags = request.args.get('tag')
|
||||||
|
tag_uuids = request.args.get('tag_uuids')
|
||||||
|
|
||||||
|
if tag_uuids:
|
||||||
|
tag_uuids = tag_uuids.split(',')
|
||||||
|
|
||||||
|
urls = request.get_data().decode('utf8').splitlines()
|
||||||
|
added = []
|
||||||
|
allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))
|
||||||
|
for url in urls:
|
||||||
|
url = url.strip()
|
||||||
|
if not len(url):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If hosts that only contain alphanumerics are allowed ("localhost" for example)
|
||||||
|
if not validators.url(url, simple_host=allow_simplehost):
|
||||||
|
return f"Invalid or unsupported URL - {url}", 400
|
||||||
|
|
||||||
|
if dedupe and self.datastore.url_exists(url):
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags, tag_uuids=tag_uuids)
|
||||||
|
added.append(new_uuid)
|
||||||
|
|
||||||
|
return added
|
||||||
145
changedetectionio/api/Notifications.py
Normal file
145
changedetectionio/api/Notifications.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from flask_expects_json import expects_json
|
||||||
|
from flask_restful import Resource
|
||||||
|
from . import auth
|
||||||
|
from flask_restful import abort, Resource
|
||||||
|
from flask import request
|
||||||
|
from . import auth
|
||||||
|
from . import schema_create_notification_urls, schema_delete_notification_urls
|
||||||
|
|
||||||
|
class Notifications(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/notifications Return Notification URL List
|
||||||
|
@apiDescription Return the Notification URL List from the configuration
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
HTTP/1.0 200
|
||||||
|
{
|
||||||
|
'notification_urls': ["notification-urls-list"]
|
||||||
|
}
|
||||||
|
@apiName Get
|
||||||
|
@apiGroup Notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'notification_urls': notification_urls,
|
||||||
|
}, 200
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
@expects_json(schema_create_notification_urls)
|
||||||
|
def post(self):
|
||||||
|
"""
|
||||||
|
@api {post} /api/v1/notifications Create Notification URLs
|
||||||
|
@apiDescription Add one or more notification URLs from the configuration
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/notifications/batch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
|
||||||
|
@apiName CreateBatch
|
||||||
|
@apiGroup Notifications
|
||||||
|
@apiSuccess (201) {Object[]} notification_urls List of added notification URLs
|
||||||
|
@apiError (400) {String} Invalid input
|
||||||
|
"""
|
||||||
|
|
||||||
|
json_data = request.get_json()
|
||||||
|
notification_urls = json_data.get("notification_urls", [])
|
||||||
|
|
||||||
|
from wtforms import ValidationError
|
||||||
|
try:
|
||||||
|
validate_notification_urls(notification_urls)
|
||||||
|
except ValidationError as e:
|
||||||
|
return str(e), 400
|
||||||
|
|
||||||
|
added_urls = []
|
||||||
|
|
||||||
|
for url in notification_urls:
|
||||||
|
clean_url = url.strip()
|
||||||
|
added_url = self.datastore.add_notification_url(clean_url)
|
||||||
|
if added_url:
|
||||||
|
added_urls.append(added_url)
|
||||||
|
|
||||||
|
if not added_urls:
|
||||||
|
return "No valid notification URLs were added", 400
|
||||||
|
|
||||||
|
return {'notification_urls': added_urls}, 201
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
@expects_json(schema_create_notification_urls)
|
||||||
|
def put(self):
|
||||||
|
"""
|
||||||
|
@api {put} /api/v1/notifications Replace Notification URLs
|
||||||
|
@apiDescription Replace all notification URLs with the provided list (can be empty)
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl -X PUT http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
|
||||||
|
@apiName Replace
|
||||||
|
@apiGroup Notifications
|
||||||
|
@apiSuccess (200) {Object[]} notification_urls List of current notification URLs
|
||||||
|
@apiError (400) {String} Invalid input
|
||||||
|
"""
|
||||||
|
json_data = request.get_json()
|
||||||
|
notification_urls = json_data.get("notification_urls", [])
|
||||||
|
|
||||||
|
from wtforms import ValidationError
|
||||||
|
try:
|
||||||
|
validate_notification_urls(notification_urls)
|
||||||
|
except ValidationError as e:
|
||||||
|
return str(e), 400
|
||||||
|
|
||||||
|
if not isinstance(notification_urls, list):
|
||||||
|
return "Invalid input format", 400
|
||||||
|
|
||||||
|
clean_urls = [url.strip() for url in notification_urls if isinstance(url, str)]
|
||||||
|
self.datastore.data['settings']['application']['notification_urls'] = clean_urls
|
||||||
|
self.datastore.needs_write = True
|
||||||
|
|
||||||
|
return {'notification_urls': clean_urls}, 200
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
@expects_json(schema_delete_notification_urls)
|
||||||
|
def delete(self):
|
||||||
|
"""
|
||||||
|
@api {delete} /api/v1/notifications Delete Notification URLs
|
||||||
|
@apiDescription Deletes one or more notification URLs from the configuration
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/notifications -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
|
||||||
|
@apiParam {String[]} notification_urls The notification URLs to delete.
|
||||||
|
@apiName Delete
|
||||||
|
@apiGroup Notifications
|
||||||
|
@apiSuccess (204) {String} OK Deleted
|
||||||
|
@apiError (400) {String} No matching notification URLs found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
json_data = request.get_json()
|
||||||
|
urls_to_delete = json_data.get("notification_urls", [])
|
||||||
|
if not isinstance(urls_to_delete, list):
|
||||||
|
abort(400, message="Expected a list of notification URLs.")
|
||||||
|
|
||||||
|
notification_urls = self.datastore.data['settings']['application'].get('notification_urls', [])
|
||||||
|
deleted = []
|
||||||
|
|
||||||
|
for url in urls_to_delete:
|
||||||
|
clean_url = url.strip()
|
||||||
|
if clean_url in notification_urls:
|
||||||
|
notification_urls.remove(clean_url)
|
||||||
|
deleted.append(clean_url)
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
abort(400, message="No matching notification URLs found.")
|
||||||
|
|
||||||
|
self.datastore.data['settings']['application']['notification_urls'] = notification_urls
|
||||||
|
self.datastore.needs_write = True
|
||||||
|
|
||||||
|
return 'OK', 204
|
||||||
|
|
||||||
|
def validate_notification_urls(notification_urls):
|
||||||
|
from changedetectionio.forms import ValidateAppRiseServers
|
||||||
|
validator = ValidateAppRiseServers()
|
||||||
|
class DummyForm: pass
|
||||||
|
dummy_form = DummyForm()
|
||||||
|
field = type("Field", (object,), {"data": notification_urls, "gettext": lambda self, x: x})()
|
||||||
|
validator(dummy_form, field)
|
||||||
51
changedetectionio/api/Search.py
Normal file
51
changedetectionio/api/Search.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from flask_restful import Resource, abort
|
||||||
|
from flask import request
|
||||||
|
from . import auth
|
||||||
|
|
||||||
|
class Search(Resource):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
# datastore is a black box dependency
|
||||||
|
self.datastore = kwargs['datastore']
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
@api {get} /api/v1/search Search for watches
|
||||||
|
@apiDescription Search watches by URL or title text
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1?tag=Favourites" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
curl "http://localhost:5000/api/v1/search?q=https://example.com?partial=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
@apiName Search
|
||||||
|
@apiGroup Watch Management
|
||||||
|
@apiQuery {String} q Search query to match against watch URLs and titles
|
||||||
|
@apiQuery {String} [tag] Optional name of tag to limit results (name not UUID)
|
||||||
|
@apiQuery {String} [partial] Allow partial matching of URL query
|
||||||
|
@apiSuccess (200) {Object} JSON Object containing matched watches
|
||||||
|
"""
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
tag_limit = request.args.get('tag', '').strip()
|
||||||
|
from changedetectionio.strtobool import strtobool
|
||||||
|
partial = bool(strtobool(request.args.get('partial', '0'))) if 'partial' in request.args else False
|
||||||
|
|
||||||
|
# Require a search query
|
||||||
|
if not query:
|
||||||
|
abort(400, message="Search query 'q' parameter is required")
|
||||||
|
|
||||||
|
# Use the search function from the datastore
|
||||||
|
matching_uuids = self.datastore.search_watches_for_url(query=query, tag_limit=tag_limit, partial=partial)
|
||||||
|
|
||||||
|
# Build the response with watch details
|
||||||
|
results = {}
|
||||||
|
for uuid in matching_uuids:
|
||||||
|
watch = self.datastore.data['watching'].get(uuid)
|
||||||
|
results[uuid] = {
|
||||||
|
'last_changed': watch.last_changed,
|
||||||
|
'last_checked': watch['last_checked'],
|
||||||
|
'last_error': watch['last_error'],
|
||||||
|
'title': watch['title'],
|
||||||
|
'url': watch['url'],
|
||||||
|
'viewed': watch.viewed
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, 200
|
||||||
54
changedetectionio/api/SystemInfo.py
Normal file
54
changedetectionio/api/SystemInfo.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from flask_restful import Resource
|
||||||
|
from . import auth
|
||||||
|
|
||||||
|
|
||||||
|
class SystemInfo(Resource):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
# datastore is a black box dependency
|
||||||
|
self.datastore = kwargs['datastore']
|
||||||
|
self.update_q = kwargs['update_q']
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
@api {get} /api/v1/systeminfo Return system info
|
||||||
|
@apiDescription Return some info about the current system state
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
HTTP/1.0 200
|
||||||
|
{
|
||||||
|
'queue_size': 10 ,
|
||||||
|
'overdue_watches': ["watch-uuid-list"],
|
||||||
|
'uptime': 38344.55,
|
||||||
|
'watch_count': 800,
|
||||||
|
'version': "0.40.1"
|
||||||
|
}
|
||||||
|
@apiName Get Info
|
||||||
|
@apiGroup System Information
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
overdue_watches = []
|
||||||
|
|
||||||
|
# Check all watches and report which have not been checked but should have been
|
||||||
|
|
||||||
|
for uuid, watch in self.datastore.data.get('watching', {}).items():
|
||||||
|
# see if now - last_checked is greater than the time that should have been
|
||||||
|
# this is not super accurate (maybe they just edited it) but better than nothing
|
||||||
|
t = watch.threshold_seconds()
|
||||||
|
if not t:
|
||||||
|
# Use the system wide default
|
||||||
|
t = self.datastore.threshold_seconds
|
||||||
|
|
||||||
|
time_since_check = time.time() - watch.get('last_checked')
|
||||||
|
|
||||||
|
# Allow 5 minutes of grace time before we decide it's overdue
|
||||||
|
if time_since_check - (5 * 60) > t:
|
||||||
|
overdue_watches.append(uuid)
|
||||||
|
from changedetectionio import __version__ as main_version
|
||||||
|
return {
|
||||||
|
'queue_size': self.update_q.qsize(),
|
||||||
|
'overdue_watches': overdue_watches,
|
||||||
|
'uptime': round(time.time() - self.datastore.start_time, 2),
|
||||||
|
'watch_count': len(self.datastore.data.get('watching', {})),
|
||||||
|
'version': main_version
|
||||||
|
}, 200
|
||||||
156
changedetectionio/api/Tags.py
Normal file
156
changedetectionio/api/Tags.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
from flask_expects_json import expects_json
|
||||||
|
from flask_restful import abort, Resource
|
||||||
|
from flask import request
|
||||||
|
from . import auth
|
||||||
|
|
||||||
|
# Import schemas from __init__.py
|
||||||
|
from . import schema_tag, schema_create_tag, schema_update_tag
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Resource):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
# datastore is a black box dependency
|
||||||
|
self.datastore = kwargs['datastore']
|
||||||
|
|
||||||
|
# Get information about a single tag
|
||||||
|
# curl http://localhost:5000/api/v1/tag/<string:uuid>
|
||||||
|
@auth.check_token
|
||||||
|
def get(self, uuid):
|
||||||
|
"""
|
||||||
|
@api {get} /api/v1/tag/:uuid Single tag - get data or toggle notification muting.
|
||||||
|
@apiDescription Retrieve tag information and set notification_muted status
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=muted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
@apiName Tag
|
||||||
|
@apiGroup Tag
|
||||||
|
@apiParam {uuid} uuid Tag unique ID.
|
||||||
|
@apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
|
||||||
|
@apiSuccess (200) {String} OK When muted operation OR full JSON object of the tag
|
||||||
|
@apiSuccess (200) {JSON} TagJSON JSON Full JSON object of the tag
|
||||||
|
"""
|
||||||
|
from copy import deepcopy
|
||||||
|
tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid))
|
||||||
|
if not tag:
|
||||||
|
abort(404, message=f'No tag exists with the UUID of {uuid}')
|
||||||
|
|
||||||
|
if request.args.get('muted', '') == 'muted':
|
||||||
|
self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True
|
||||||
|
return "OK", 200
|
||||||
|
elif request.args.get('muted', '') == 'unmuted':
|
||||||
|
self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = False
|
||||||
|
return "OK", 200
|
||||||
|
|
||||||
|
return tag
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
def delete(self, uuid):
|
||||||
|
"""
|
||||||
|
@api {delete} /api/v1/tag/:uuid Delete a tag and remove it from all watches
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
@apiParam {uuid} uuid Tag unique ID.
|
||||||
|
@apiName DeleteTag
|
||||||
|
@apiGroup Tag
|
||||||
|
@apiSuccess (200) {String} OK Was deleted
|
||||||
|
"""
|
||||||
|
if not self.datastore.data['settings']['application']['tags'].get(uuid):
|
||||||
|
abort(400, message='No tag exists with the UUID of {}'.format(uuid))
|
||||||
|
|
||||||
|
# Delete the tag, and any tag reference
|
||||||
|
del self.datastore.data['settings']['application']['tags'][uuid]
|
||||||
|
|
||||||
|
# Remove tag from all watches
|
||||||
|
for watch_uuid, watch in self.datastore.data['watching'].items():
|
||||||
|
if watch.get('tags') and uuid in watch['tags']:
|
||||||
|
watch['tags'].remove(uuid)
|
||||||
|
|
||||||
|
return 'OK', 204
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
@expects_json(schema_update_tag)
|
||||||
|
def put(self, uuid):
|
||||||
|
"""
|
||||||
|
@api {put} /api/v1/tag/:uuid Update tag information
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
Update (PUT)
|
||||||
|
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag Title"}'
|
||||||
|
|
||||||
|
@apiDescription Updates an existing tag using JSON
|
||||||
|
@apiParam {uuid} uuid Tag unique ID.
|
||||||
|
@apiName UpdateTag
|
||||||
|
@apiGroup Tag
|
||||||
|
@apiSuccess (200) {String} OK Was updated
|
||||||
|
@apiSuccess (500) {String} ERR Some other error
|
||||||
|
"""
|
||||||
|
tag = self.datastore.data['settings']['application']['tags'].get(uuid)
|
||||||
|
if not tag:
|
||||||
|
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
|
||||||
|
|
||||||
|
tag.update(request.json)
|
||||||
|
self.datastore.needs_write_urgent = True
|
||||||
|
|
||||||
|
return "OK", 200
|
||||||
|
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
# Only cares for {'title': 'xxxx'}
|
||||||
|
def post(self):
|
||||||
|
"""
|
||||||
|
@api {post} /api/v1/watch Create a single tag
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"name": "Work related"}'
|
||||||
|
@apiName Create
|
||||||
|
@apiGroup Tag
|
||||||
|
@apiSuccess (200) {String} OK Was created
|
||||||
|
@apiSuccess (500) {String} ERR Some other error
|
||||||
|
"""
|
||||||
|
|
||||||
|
json_data = request.get_json()
|
||||||
|
title = json_data.get("title",'').strip()
|
||||||
|
|
||||||
|
|
||||||
|
new_uuid = self.datastore.add_tag(title=title)
|
||||||
|
if new_uuid:
|
||||||
|
return {'uuid': new_uuid}, 201
|
||||||
|
else:
|
||||||
|
return "Invalid or unsupported tag", 400
|
||||||
|
|
||||||
|
class Tags(Resource):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
# datastore is a black box dependency
|
||||||
|
self.datastore = kwargs['datastore']
|
||||||
|
|
||||||
|
@auth.check_token
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
@api {get} /api/v1/tags List tags
|
||||||
|
@apiDescription Return list of available tags
|
||||||
|
@apiExample {curl} Example usage:
|
||||||
|
curl http://localhost:5000/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||||
|
{
|
||||||
|
"cc0cfffa-f449-477b-83ea-0caafd1dc091": {
|
||||||
|
"title": "Tech News",
|
||||||
|
"notification_muted": false,
|
||||||
|
"date_created": 1677103794
|
||||||
|
},
|
||||||
|
"e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
|
||||||
|
"title": "Shopping",
|
||||||
|
"notification_muted": true,
|
||||||
|
"date_created": 1676662819
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@apiName ListTags
|
||||||
|
@apiGroup Tag Management
|
||||||
|
@apiSuccess (200) {String} OK JSON dict
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
|
||||||
|
result[uuid] = {
|
||||||
|
'date_created': tag.get('date_created', 0),
|
||||||
|
'notification_muted': tag.get('notification_muted', False),
|
||||||
|
'title': tag.get('title', ''),
|
||||||
|
'uuid': tag.get('uuid')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, 200
|
||||||
@@ -9,20 +9,9 @@ import validators
|
|||||||
from . import auth
|
from . import auth
|
||||||
import copy
|
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):
|
class Watch(Resource):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
@@ -285,8 +274,6 @@ class CreateWatch(Resource):
|
|||||||
list = {}
|
list = {}
|
||||||
|
|
||||||
tag_limit = request.args.get('tag', '').lower()
|
tag_limit = request.args.get('tag', '').lower()
|
||||||
|
|
||||||
|
|
||||||
for uuid, watch in self.datastore.data['watching'].items():
|
for uuid, watch in self.datastore.data['watching'].items():
|
||||||
# Watch tags by name (replace the other calls?)
|
# Watch tags by name (replace the other calls?)
|
||||||
tags = self.datastore.get_all_tags_for_watch(uuid=uuid)
|
tags = self.datastore.get_all_tags_for_watch(uuid=uuid)
|
||||||
@@ -308,109 +295,3 @@ class CreateWatch(Resource):
|
|||||||
return {'status': "OK"}, 200
|
return {'status': "OK"}, 200
|
||||||
|
|
||||||
return list, 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
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
schema_notification_urls = copy.deepcopy(schema)
|
||||||
|
schema_create_notification_urls = copy.deepcopy(schema_notification_urls)
|
||||||
|
schema_create_notification_urls['required'] = ['notification_urls']
|
||||||
|
schema_delete_notification_urls = copy.deepcopy(schema_notification_urls)
|
||||||
|
schema_delete_notification_urls['required'] = ['notification_urls']
|
||||||
|
|
||||||
|
# Import all API resources
|
||||||
|
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch
|
||||||
|
from .Tags import Tags, Tag
|
||||||
|
from .Import import Import
|
||||||
|
from .SystemInfo import SystemInfo
|
||||||
|
from .Notifications import Notifications
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Responsible for building the storage dict into a set of rules ("JSON Schema") acceptable via the API
|
# Responsible for building the storage dict into a set of rules ("JSON Schema") acceptable via the API
|
||||||
# Probably other ways to solve this when the backend switches to some ORM
|
# Probably other ways to solve this when the backend switches to some ORM
|
||||||
|
from changedetectionio.notification import valid_notification_formats
|
||||||
|
|
||||||
|
|
||||||
def build_time_between_check_json_schema():
|
def build_time_between_check_json_schema():
|
||||||
# Setup time between check schema
|
# Setup time between check schema
|
||||||
@@ -98,8 +100,6 @@ def build_watch_json_schema(d):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
from changedetectionio.notification import valid_notification_formats
|
|
||||||
|
|
||||||
schema['properties']['notification_format'] = {'type': 'string',
|
schema['properties']['notification_format'] = {'type': 'string',
|
||||||
'enum': list(valid_notification_formats.keys())
|
'enum': list(valid_notification_formats.keys())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,11 @@ def check_token(f):
|
|||||||
datastore = args[0].datastore
|
datastore = args[0].datastore
|
||||||
|
|
||||||
config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled')
|
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')
|
config_api_token = datastore.data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
if api_key_header != config_api_token:
|
# 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(
|
return make_response(
|
||||||
jsonify("Invalid access - API key invalid."), 403
|
jsonify("Invalid access - API key invalid."), 403
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
from changedetectionio import apprise_plugin
|
|
||||||
import apprise
|
|
||||||
|
|
||||||
# Create our AppriseAsset and populate it with some of our new values:
|
|
||||||
# https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object
|
|
||||||
asset = apprise.AppriseAsset(
|
|
||||||
image_url_logo='https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png'
|
|
||||||
)
|
|
||||||
|
|
||||||
asset.app_id = "changedetection.io"
|
|
||||||
asset.app_desc = "ChangeDetection.io best and simplest website monitoring and change detection"
|
|
||||||
asset.app_url = "https://changedetection.io"
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# include the decorator
|
|
||||||
from apprise.decorators import notify
|
|
||||||
from loguru import logger
|
|
||||||
from requests.structures import CaseInsensitiveDict
|
|
||||||
|
|
||||||
|
|
||||||
@notify(on="delete")
|
|
||||||
@notify(on="deletes")
|
|
||||||
@notify(on="get")
|
|
||||||
@notify(on="gets")
|
|
||||||
@notify(on="post")
|
|
||||||
@notify(on="posts")
|
|
||||||
@notify(on="put")
|
|
||||||
@notify(on="puts")
|
|
||||||
def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
from urllib.parse import unquote_plus
|
|
||||||
from apprise.utils.parse import parse_url as apprise_parse_url
|
|
||||||
|
|
||||||
url = kwargs['meta'].get('url')
|
|
||||||
schema = kwargs['meta'].get('schema').lower().strip()
|
|
||||||
|
|
||||||
# Choose POST, GET etc from requests
|
|
||||||
method = re.sub(rf's$', '', schema)
|
|
||||||
requests_method = getattr(requests, method)
|
|
||||||
|
|
||||||
params = CaseInsensitiveDict({}) # Added to requests
|
|
||||||
auth = None
|
|
||||||
has_error = False
|
|
||||||
|
|
||||||
# Convert /foobar?+some-header=hello to proper header dictionary
|
|
||||||
results = apprise_parse_url(url)
|
|
||||||
|
|
||||||
# Add our headers that the user can potentially over-ride if they wish
|
|
||||||
# to to our returned result set and tidy entries by unquoting them
|
|
||||||
headers = CaseInsensitiveDict({unquote_plus(x): unquote_plus(y)
|
|
||||||
for x, y in results['qsd+'].items()})
|
|
||||||
|
|
||||||
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
|
|
||||||
# In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
|
|
||||||
# but here we are making straight requests, so we need todo convert this against apprise's logic
|
|
||||||
for k, v in results['qsd'].items():
|
|
||||||
if not k.strip('+-') in results['qsd+'].keys():
|
|
||||||
params[unquote_plus(k)] = unquote_plus(v)
|
|
||||||
|
|
||||||
# Determine Authentication
|
|
||||||
auth = ''
|
|
||||||
if results.get('user') and results.get('password'):
|
|
||||||
auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user')))
|
|
||||||
elif results.get('user'):
|
|
||||||
auth = (unquote_plus(results.get('user')))
|
|
||||||
|
|
||||||
# If it smells like it could be JSON and no content-type was already set, offer a default content type.
|
|
||||||
if body and '{' in body[:100] and not headers.get('Content-Type'):
|
|
||||||
json_header = 'application/json; charset=utf-8'
|
|
||||||
try:
|
|
||||||
# Try if it's JSON
|
|
||||||
json.loads(body)
|
|
||||||
headers['Content-Type'] = json_header
|
|
||||||
except ValueError as e:
|
|
||||||
logger.warning(f"Could not automatically add '{json_header}' header to the notification because the document failed to parse as JSON: {e}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
# POSTS -> HTTPS etc
|
|
||||||
if schema.lower().endswith('s'):
|
|
||||||
url = re.sub(rf'^{schema}', 'https', results.get('url'))
|
|
||||||
else:
|
|
||||||
url = re.sub(rf'^{schema}', 'http', results.get('url'))
|
|
||||||
|
|
||||||
status_str = ''
|
|
||||||
try:
|
|
||||||
r = requests_method(url,
|
|
||||||
auth=auth,
|
|
||||||
data=body.encode('utf-8') if type(body) is str else body,
|
|
||||||
headers=headers,
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
|
|
||||||
if not (200 <= r.status_code < 300):
|
|
||||||
status_str = f"Error sending '{method.upper()}' request to {url} - Status: {r.status_code}: '{r.reason}'"
|
|
||||||
logger.error(status_str)
|
|
||||||
has_error = True
|
|
||||||
else:
|
|
||||||
logger.info(f"Sent '{method.upper()}' request to {url}")
|
|
||||||
has_error = False
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
status_str = f"Error sending '{method.upper()}' request to {url} - {str(e)}"
|
|
||||||
logger.error(status_str)
|
|
||||||
has_error = True
|
|
||||||
|
|
||||||
if has_error:
|
|
||||||
raise TypeError(status_str)
|
|
||||||
|
|
||||||
return True
|
|
||||||
@@ -20,10 +20,7 @@ def login_optionally_required(func):
|
|||||||
has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
|
has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
|
||||||
|
|
||||||
# Permitted
|
# 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 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
|
||||||
return func(*args, **kwargs)
|
|
||||||
# Permitted
|
|
||||||
elif request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
|
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
elif request.method in flask_login.config.EXEMPT_METHODS:
|
elif request.method in flask_login.config.EXEMPT_METHODS:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
|
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
|
||||||
|
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
@backups_blueprint.route("/", methods=['GET'])
|
@backups_blueprint.route("", methods=['GET'])
|
||||||
def index():
|
def index():
|
||||||
backups = find_backups()
|
backups = find_backups()
|
||||||
output = render_template("overview.html",
|
output = render_template("overview.html",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from loguru import logger
|
|||||||
browsersteps_sessions = {}
|
browsersteps_sessions = {}
|
||||||
io_interface_context = None
|
io_interface_context = None
|
||||||
import json
|
import json
|
||||||
import base64
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from flask import Response
|
from flask import Response
|
||||||
|
|
||||||
@@ -34,10 +33,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
from . import nonContext
|
from . import nonContext
|
||||||
from . import browser_steps
|
from . import browser_steps
|
||||||
import time
|
import time
|
||||||
global browsersteps_sessions
|
|
||||||
global io_interface_context
|
global io_interface_context
|
||||||
|
|
||||||
|
|
||||||
# We keep the playwright session open for many minutes
|
# We keep the playwright session open for many minutes
|
||||||
keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
|
keepalive_seconds = int(os.getenv('BROWSERSTEPS_MINUTES_KEEPALIVE', 10)) * 60
|
||||||
|
|
||||||
@@ -104,8 +101,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
# A new session was requested, return sessionID
|
# A new session was requested, return sessionID
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
global browsersteps_sessions
|
|
||||||
|
|
||||||
browsersteps_session_id = str(uuid.uuid4())
|
browsersteps_session_id = str(uuid.uuid4())
|
||||||
watch_uuid = request.args.get('uuid')
|
watch_uuid = request.args.get('uuid')
|
||||||
|
|
||||||
@@ -149,7 +144,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
def browsersteps_ui_update():
|
def browsersteps_ui_update():
|
||||||
import base64
|
import base64
|
||||||
import playwright._impl._errors
|
import playwright._impl._errors
|
||||||
global browsersteps_sessions
|
|
||||||
from changedetectionio.blueprint.browser_steps import browser_steps
|
from changedetectionio.blueprint.browser_steps import browser_steps
|
||||||
|
|
||||||
remaining =0
|
remaining =0
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import re
|
|||||||
from random import randint
|
from random import randint
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from changedetectionio.content_fetchers.helpers import capture_stitched_together_full_page, SCREENSHOT_SIZE_STITCH_THRESHOLD
|
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT
|
||||||
from changedetectionio.content_fetchers.base import manage_user_agent
|
from changedetectionio.content_fetchers.base import manage_user_agent
|
||||||
from changedetectionio.safe_jinja import render as jinja_render
|
from changedetectionio.safe_jinja import render as jinja_render
|
||||||
|
|
||||||
@@ -293,19 +293,16 @@ class browsersteps_live_ui(steppable_browser_interface):
|
|||||||
def get_current_state(self):
|
def get_current_state(self):
|
||||||
"""Return the screenshot and interactive elements mapping, generally always called after action_()"""
|
"""Return the screenshot and interactive elements mapping, generally always called after action_()"""
|
||||||
import importlib.resources
|
import importlib.resources
|
||||||
|
import json
|
||||||
|
# because we for now only run browser steps in playwright mode (not puppeteer mode)
|
||||||
|
from changedetectionio.content_fetchers.playwright import capture_full_page
|
||||||
|
|
||||||
xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
|
xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
self.page.wait_for_timeout(1 * 1000)
|
self.page.wait_for_timeout(1 * 1000)
|
||||||
|
|
||||||
|
screenshot = capture_full_page(page=self.page)
|
||||||
full_height = self.page.evaluate("document.documentElement.scrollHeight")
|
|
||||||
|
|
||||||
if full_height >= SCREENSHOT_SIZE_STITCH_THRESHOLD:
|
|
||||||
logger.warning(f"Page full Height: {full_height}px longer than {SCREENSHOT_SIZE_STITCH_THRESHOLD}px, using 'stitched screenshot method'.")
|
|
||||||
screenshot = capture_stitched_together_full_page(self.page)
|
|
||||||
else:
|
|
||||||
screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=40)
|
|
||||||
|
|
||||||
logger.debug(f"Time to get screenshot from browser {time.time() - now:.2f}s")
|
logger.debug(f"Time to get screenshot from browser {time.time() - now:.2f}s")
|
||||||
|
|
||||||
@@ -313,13 +310,21 @@ class browsersteps_live_ui(steppable_browser_interface):
|
|||||||
self.page.evaluate("var include_filters=''")
|
self.page.evaluate("var include_filters=''")
|
||||||
# Go find the interactive elements
|
# Go find the interactive elements
|
||||||
# @todo in the future, something smarter that can scan for elements with .click/focus etc event handlers?
|
# @todo in the future, something smarter that can scan for elements with .click/focus etc event handlers?
|
||||||
elements = 'a,button,input,select,textarea,i,th,td,p,li,h1,h2,h3,h4,div,span'
|
|
||||||
xpath_element_js = xpath_element_js.replace('%ELEMENTS%', elements)
|
|
||||||
|
|
||||||
xpath_data = self.page.evaluate("async () => {" + xpath_element_js + "}")
|
self.page.request_gc()
|
||||||
|
|
||||||
|
scan_elements = 'a,button,input,select,textarea,i,th,td,p,li,h1,h2,h3,h4,div,span'
|
||||||
|
|
||||||
|
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
|
||||||
|
xpath_data = json.loads(self.page.evaluate(xpath_element_js, {
|
||||||
|
"visualselector_xpath_selectors": scan_elements,
|
||||||
|
"max_height": MAX_TOTAL_HEIGHT
|
||||||
|
}))
|
||||||
|
self.page.request_gc()
|
||||||
|
|
||||||
# So the JS will find the smallest one first
|
# So the JS will find the smallest one first
|
||||||
xpath_data['size_pos'] = sorted(xpath_data['size_pos'], key=lambda k: k['width'] * k['height'], reverse=True)
|
xpath_data['size_pos'] = sorted(xpath_data['size_pos'], key=lambda k: k['width'] * k['height'], reverse=True)
|
||||||
logger.debug(f"Time to scrape xpath element data in browser {time.time()-now:.2f}s")
|
logger.debug(f"Time to scrape xPath element data in browser {time.time()-now:.2f}s")
|
||||||
|
|
||||||
# playwright._impl._api_types.Error: Browser closed.
|
# playwright._impl._api_types.Error: Browser closed.
|
||||||
# @todo show some countdown timer?
|
# @todo show some countdown timer?
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||||
|
|
||||||
if len(importer_handler.remaining_data) == 0:
|
if len(importer_handler.remaining_data) == 0:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('watchlist.index'))
|
||||||
else:
|
else:
|
||||||
remaining_urls = importer_handler.remaining_data
|
remaining_urls = importer_handler.remaining_data
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
|
|||||||
datastore.data['watching'][uuid]['processor'] = 'restock_diff'
|
datastore.data['watching'][uuid]['processor'] = 'restock_diff'
|
||||||
datastore.data['watching'][uuid].clear_watch()
|
datastore.data['watching'][uuid].clear_watch()
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("watchlist.index"))
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@price_data_follower_blueprint.route("/<string:uuid>/reject", methods=['GET'])
|
@price_data_follower_blueprint.route("/<string:uuid>/reject", methods=['GET'])
|
||||||
def reject(uuid):
|
def reject(uuid):
|
||||||
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT
|
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
|
return price_data_follower_blueprint
|
||||||
|
|||||||
@@ -1,103 +1 @@
|
|||||||
import time
|
RSS_FORMAT_TYPES = [('plaintext', 'Plain text'), ('html', 'HTML Color')]
|
||||||
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
|
|
||||||
|
|||||||
147
changedetectionio/blueprint/rss/blueprint.py
Normal file
147
changedetectionio/blueprint/rss/blueprint.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
|
||||||
|
from changedetectionio.safe_jinja import render as jinja_render
|
||||||
|
from changedetectionio.store import ChangeDetectionStore
|
||||||
|
from feedgen.feed import FeedGenerator
|
||||||
|
from flask import Blueprint, make_response, request, url_for, redirect
|
||||||
|
from loguru import logger
|
||||||
|
import datetime
|
||||||
|
import pytz
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
BAD_CHARS_REGEX=r'[\x00-\x08\x0B\x0C\x0E-\x1F]'
|
||||||
|
|
||||||
|
# Anything that is not text/UTF-8 should be stripped before it breaks feedgen (such as binary data etc)
|
||||||
|
def scan_invalid_chars_in_rss(content):
|
||||||
|
for match in re.finditer(BAD_CHARS_REGEX, content):
|
||||||
|
i = match.start()
|
||||||
|
bad_char = content[i]
|
||||||
|
hex_value = f"0x{ord(bad_char):02x}"
|
||||||
|
# Grab context
|
||||||
|
start = max(0, i - 20)
|
||||||
|
end = min(len(content), i + 21)
|
||||||
|
context = content[start:end].replace('\n', '\\n').replace('\r', '\\r')
|
||||||
|
logger.warning(f"Invalid char {hex_value} at pos {i}: ...{context}...")
|
||||||
|
# First match is enough
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_entry_content(content):
|
||||||
|
cleaned = re.sub(BAD_CHARS_REGEX, '', content)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def construct_blueprint(datastore: ChangeDetectionStore):
|
||||||
|
rss_blueprint = Blueprint('rss', __name__)
|
||||||
|
|
||||||
|
# Some RSS reader situations ended up with rss/ (forward slash after RSS) due
|
||||||
|
# to some earlier blueprint rerouting work, it should goto feed.
|
||||||
|
@rss_blueprint.route("/", methods=['GET'])
|
||||||
|
def extraslash():
|
||||||
|
return redirect(url_for('rss.feed'))
|
||||||
|
|
||||||
|
# Import the login decorator if needed
|
||||||
|
# from changedetectionio.auth_decorator import login_optionally_required
|
||||||
|
@rss_blueprint.route("", methods=['GET'])
|
||||||
|
def feed():
|
||||||
|
now = time.time()
|
||||||
|
# Always requires token set
|
||||||
|
app_rss_token = datastore.data['settings']['application'].get('rss_access_token')
|
||||||
|
rss_url_token = request.args.get('token')
|
||||||
|
if rss_url_token != app_rss_token:
|
||||||
|
return "Access denied, bad token", 403
|
||||||
|
|
||||||
|
from changedetectionio import diff
|
||||||
|
limit_tag = request.args.get('tag', '').lower().strip()
|
||||||
|
# Be sure limit_tag is a uuid
|
||||||
|
for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
|
||||||
|
if limit_tag == tag.get('title', '').lower().strip():
|
||||||
|
limit_tag = uuid
|
||||||
|
|
||||||
|
# Sort by last_changed and add the uuid which is usually the key..
|
||||||
|
sorted_watches = []
|
||||||
|
|
||||||
|
# @todo needs a .itemsWithTag() or something - then we can use that in Jinaj2 and throw this away
|
||||||
|
for uuid, watch in datastore.data['watching'].items():
|
||||||
|
# @todo tag notification_muted skip also (improve Watch model)
|
||||||
|
if datastore.data['settings']['application'].get('rss_hide_muted_watches') and watch.get('notification_muted'):
|
||||||
|
continue
|
||||||
|
if limit_tag and not limit_tag in watch['tags']:
|
||||||
|
continue
|
||||||
|
watch['uuid'] = uuid
|
||||||
|
sorted_watches.append(watch)
|
||||||
|
|
||||||
|
sorted_watches.sort(key=lambda x: x.last_changed, reverse=False)
|
||||||
|
|
||||||
|
fg = FeedGenerator()
|
||||||
|
fg.title('changedetection.io')
|
||||||
|
fg.description('Feed description')
|
||||||
|
fg.link(href='https://changedetection.io')
|
||||||
|
|
||||||
|
html_colour_enable = False
|
||||||
|
if datastore.data['settings']['application'].get('rss_content_format') == 'html':
|
||||||
|
html_colour_enable = True
|
||||||
|
|
||||||
|
for watch in sorted_watches:
|
||||||
|
|
||||||
|
dates = list(watch.history.keys())
|
||||||
|
# Re #521 - Don't bother processing this one if theres less than 2 snapshots, means we never had a change detected.
|
||||||
|
if len(dates) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not watch.viewed:
|
||||||
|
# Re #239 - GUID needs to be individual for each event
|
||||||
|
# @todo In the future make this a configurable link back (see work on BASE_URL https://github.com/dgtlmoon/changedetection.io/pull/228)
|
||||||
|
guid = "{}/{}".format(watch['uuid'], watch.last_changed)
|
||||||
|
fe = fg.add_entry()
|
||||||
|
|
||||||
|
# Include a link to the diff page, they will have to login here to see if password protection is enabled.
|
||||||
|
# Description is the page you watch, link takes you to the diff JS UI page
|
||||||
|
# Dict val base_url will get overriden with the env var if it is set.
|
||||||
|
ext_base_url = datastore.data['settings']['application'].get('active_base_url')
|
||||||
|
# @todo fix
|
||||||
|
|
||||||
|
# Because we are called via whatever web server, flask should figure out the right path (
|
||||||
|
diff_link = {'href': url_for('ui.ui_views.diff_history_page', uuid=watch['uuid'], _external=True)}
|
||||||
|
|
||||||
|
fe.link(link=diff_link)
|
||||||
|
|
||||||
|
# @todo watch should be a getter - watch.get('title') (internally if URL else..)
|
||||||
|
|
||||||
|
watch_title = watch.get('title') if watch.get('title') else watch.get('url')
|
||||||
|
fe.title(title=watch_title)
|
||||||
|
try:
|
||||||
|
|
||||||
|
html_diff = diff.render_diff(previous_version_file_contents=watch.get_history_snapshot(dates[-2]),
|
||||||
|
newest_version_file_contents=watch.get_history_snapshot(dates[-1]),
|
||||||
|
include_equal=False,
|
||||||
|
line_feed_sep="<br>",
|
||||||
|
html_colour=html_colour_enable
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
html_diff = f"History snapshot file for watch {watch.get('uuid')}@{watch.last_changed} - '{watch.get('title')} not found."
|
||||||
|
|
||||||
|
# @todo Make this configurable and also consider html-colored markup
|
||||||
|
# @todo User could decide if <link> goes to the diff page, or to the watch link
|
||||||
|
rss_template = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_title}}</a></h4>\n<p>{{html_diff}}</p>\n</body></html>\n"
|
||||||
|
|
||||||
|
content = jinja_render(template_str=rss_template, watch_title=watch_title, html_diff=html_diff, watch_url=watch.link)
|
||||||
|
|
||||||
|
# Out of range chars could also break feedgen
|
||||||
|
if scan_invalid_chars_in_rss(content):
|
||||||
|
content = clean_entry_content(content)
|
||||||
|
|
||||||
|
fe.content(content=content, type='CDATA')
|
||||||
|
fe.guid(guid, permalink=False)
|
||||||
|
dt = datetime.datetime.fromtimestamp(int(watch.newest_history_key))
|
||||||
|
dt = dt.replace(tzinfo=pytz.UTC)
|
||||||
|
fe.pubDate(dt)
|
||||||
|
|
||||||
|
response = make_response(fg.rss_str())
|
||||||
|
response.headers.set('Content-Type', 'application/rss+xml;charset=utf-8')
|
||||||
|
logger.trace(f"RSS generated in {time.time() - now:.3f}s")
|
||||||
|
return response
|
||||||
|
|
||||||
|
return rss_blueprint
|
||||||
@@ -13,7 +13,7 @@ from changedetectionio.auth_decorator import login_optionally_required
|
|||||||
def construct_blueprint(datastore: ChangeDetectionStore):
|
def construct_blueprint(datastore: ChangeDetectionStore):
|
||||||
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
|
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
|
||||||
|
|
||||||
@settings_blueprint.route("/", methods=['GET', "POST"])
|
@settings_blueprint.route("", methods=['GET', "POST"])
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
from changedetectionio import forms
|
from changedetectionio import forms
|
||||||
@@ -74,7 +74,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
datastore.needs_write_urgent = True
|
datastore.needs_write_urgent = True
|
||||||
flash("Password protection enabled.", 'notice')
|
flash("Password protection enabled.", 'notice')
|
||||||
flask_login.logout_user()
|
flask_login.logout_user()
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('watchlist.index'))
|
||||||
|
|
||||||
datastore.needs_write_urgent = True
|
datastore.needs_write_urgent = True
|
||||||
flash("Settings updated.")
|
flash("Settings updated.")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<li class="tab"><a href="#notifications">Notifications</a></li>
|
<li class="tab"><a href="#notifications">Notifications</a></li>
|
||||||
<li class="tab"><a href="#fetching">Fetching</a></li>
|
<li class="tab"><a href="#fetching">Fetching</a></li>
|
||||||
<li class="tab"><a href="#filters">Global Filters</a></li>
|
<li class="tab"><a href="#filters">Global Filters</a></li>
|
||||||
|
<li class="tab"><a href="#ui-options">UI Options</a></li>
|
||||||
<li class="tab"><a href="#api">API</a></li>
|
<li class="tab"><a href="#api">API</a></li>
|
||||||
<li class="tab"><a href="#timedate">Time & Date</a></li>
|
<li class="tab"><a href="#timedate">Time & Date</a></li>
|
||||||
<li class="tab"><a href="#proxies">CAPTCHA & Proxies</a></li>
|
<li class="tab"><a href="#proxies">CAPTCHA & Proxies</a></li>
|
||||||
@@ -78,7 +79,10 @@
|
|||||||
{{ render_field(form.application.form.pager_size) }}
|
{{ 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>
|
<span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span>
|
||||||
</div>
|
</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">
|
<div class="pure-control-group">
|
||||||
{{ render_checkbox_field(form.application.form.extract_title_as_title) }}
|
{{ 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>
|
<span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span>
|
||||||
@@ -214,7 +218,7 @@ nav
|
|||||||
<a id="chrome-extension-link"
|
<a id="chrome-extension-link"
|
||||||
title="Try our new Chrome Extension!"
|
title="Try our new Chrome Extension!"
|
||||||
href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop">
|
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') }}" alt="Chrome">
|
<img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='google-chrome-icon.png') }}" alt="Chrome">
|
||||||
Chrome Webstore
|
Chrome Webstore
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -237,6 +241,12 @@ nav
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tab-pane-inner" id="ui-options">
|
||||||
|
<div class="pure-control-group">
|
||||||
|
{{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }}
|
||||||
|
<span class="pure-form-message-inline">Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="tab-pane-inner" id="proxies">
|
<div class="tab-pane-inner" id="proxies">
|
||||||
<div id="recommended-proxy">
|
<div id="recommended-proxy">
|
||||||
<div>
|
<div>
|
||||||
@@ -299,7 +309,7 @@ nav
|
|||||||
<div id="actions">
|
<div id="actions">
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
{{ render_button(form.save_button) }}
|
{{ 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>
|
<a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
/*const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}');*/
|
/*const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}');*/
|
||||||
/*{% endif %}*/
|
/*{% endif %}*/
|
||||||
|
|
||||||
|
{% set has_tag_filters_extra='' %}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,59 +47,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||||
<div class="pure-control-group">
|
<p>These settings are <strong><i>added</i></strong> to any existing watch configurations.</p>
|
||||||
{% set field = render_field(form.include_filters,
|
{% include "edit/include_subtract.html" %}
|
||||||
rows=5,
|
<div class="text-filtering border-fieldset">
|
||||||
placeholder="#example
|
<h3>Text filtering</h3>
|
||||||
xpath://body/div/span[contains(@class, 'example-class')]",
|
{% include "edit/text-options.html" %}
|
||||||
class="m-d")
|
|
||||||
%}
|
|
||||||
{{ field }}
|
|
||||||
{% if '/text()' in field %}
|
|
||||||
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br>
|
|
||||||
{% endif %}
|
|
||||||
<span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
|
|
||||||
<div data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div>
|
|
||||||
<ul id="advanced-help-selectors">
|
|
||||||
<li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
|
|
||||||
<li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed).
|
|
||||||
<ul>
|
|
||||||
<li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li>
|
|
||||||
{% if jq_support %}
|
|
||||||
<li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>. Prefix <code>jqraw:</code> outputs the results as text instead of a JSON list.</li>
|
|
||||||
{% else %}
|
|
||||||
<li>jq support not installed</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>XPath - Limit text to this XPath rule, simply start with a forward-slash. To specify XPath to be used explicitly or the XPath rule starts with an XPath function: Prefix with <code>xpath:</code>
|
|
||||||
<ul>
|
|
||||||
<li>Example: <code>//*[contains(@class, 'sametext')]</code> or <code>xpath:count(//*[contains(@class, 'sametext')])</code>, <a
|
|
||||||
href="http://xpather.com/" target="new">test your XPath here</a></li>
|
|
||||||
<li>Example: Get all titles from an RSS feed <code>//title/text()</code></li>
|
|
||||||
<li>To use XPath1.0: Prefix with <code>xpath1:</code></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a
|
|
||||||
href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="pure-control-group">
|
|
||||||
{{ render_field(form.subtractive_selectors, rows=5, placeholder="header
|
|
||||||
footer
|
|
||||||
nav
|
|
||||||
.stockticker
|
|
||||||
//*[contains(text(), 'Advertisement')]") }}
|
|
||||||
<span class="pure-form-message-inline">
|
|
||||||
<ul>
|
|
||||||
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
|
|
||||||
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
|
|
||||||
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# rendered sub Template #}
|
{# rendered sub Template #}
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
<td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</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>
|
<td>
|
||||||
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>
|
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>
|
||||||
<a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a>
|
<a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
else:
|
else:
|
||||||
flash("Cleared snapshot history for watch {}".format(uuid))
|
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'])
|
@ui_blueprint.route("/clear_history", methods=['GET', 'POST'])
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
@@ -52,7 +52,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
else:
|
else:
|
||||||
flash('Incorrect confirmation text.', 'error')
|
flash('Incorrect confirmation text.', 'error')
|
||||||
|
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('watchlist.index'))
|
||||||
|
|
||||||
output = render_template("clear_all_history.html")
|
output = render_template("clear_all_history.html")
|
||||||
return output
|
return output
|
||||||
@@ -68,7 +68,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
continue
|
continue
|
||||||
datastore.set_last_viewed(watch_uuid, int(time.time()))
|
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'])
|
@ui_blueprint.route("/delete", methods=['GET'])
|
||||||
@login_optionally_required
|
@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():
|
if uuid != 'all' and not uuid in datastore.data['watching'].keys():
|
||||||
flash('The watch by UUID {} does not exist.'.format(uuid), 'error')
|
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
|
# More for testing, possible to return the first/only
|
||||||
if uuid == 'first':
|
if uuid == 'first':
|
||||||
@@ -85,7 +85,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
datastore.delete(uuid)
|
datastore.delete(uuid)
|
||||||
flash('Deleted.')
|
flash('Deleted.')
|
||||||
|
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('watchlist.index'))
|
||||||
|
|
||||||
@ui_blueprint.route("/clone", methods=['GET'])
|
@ui_blueprint.route("/clone", methods=['GET'])
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
@@ -96,12 +96,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
uuid = list(datastore.data['watching'].keys()).pop()
|
uuid = list(datastore.data['watching'].keys()).pop()
|
||||||
|
|
||||||
new_uuid = datastore.clone(uuid)
|
new_uuid = datastore.clone(uuid)
|
||||||
if new_uuid:
|
|
||||||
if not datastore.data['watching'].get(uuid).get('paused'):
|
if not datastore.data['watching'].get(uuid).get('paused'):
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
|
||||||
flash('Cloned.')
|
|
||||||
|
|
||||||
return redirect(url_for('index'))
|
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'])
|
@ui_blueprint.route("/checknow", methods=['GET'])
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
@@ -124,7 +125,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# Recheck all, including muted
|
# Recheck all, including muted
|
||||||
for watch_uuid, watch in datastore.data['watching'].items():
|
# Get most overdue first
|
||||||
|
for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):
|
||||||
|
watch_uuid = k[0]
|
||||||
|
watch = k[1]
|
||||||
if not watch['paused']:
|
if not watch['paused']:
|
||||||
if watch_uuid not in running_uuids:
|
if watch_uuid not in running_uuids:
|
||||||
if with_errors and not watch.get('last_error'):
|
if with_errors and not watch.get('last_error'):
|
||||||
@@ -139,11 +143,11 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
if i == 1:
|
if i == 1:
|
||||||
flash("Queued 1 watch for rechecking.")
|
flash("Queued 1 watch for rechecking.")
|
||||||
if i > 1:
|
if i > 1:
|
||||||
flash("Queued {} watches for rechecking.".format(i))
|
flash(f"Queued {i} watches for rechecking.")
|
||||||
if i == 0:
|
if i == 0:
|
||||||
flash("No watches available to recheck.")
|
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'])
|
@ui_blueprint.route("/form/checkbox-operations", methods=['POST'])
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
@@ -231,7 +235,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
elif (op == 'assign-tag'):
|
elif (op == 'assign-tag'):
|
||||||
op_extradata = request.form.get('op_extradata', '').strip()
|
op_extradata = request.form.get('op_extradata', '').strip()
|
||||||
if op_extradata:
|
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:
|
if op_extradata and tag_uuid:
|
||||||
for uuid in uuids:
|
for uuid in uuids:
|
||||||
uuid = uuid.strip()
|
uuid = uuid.strip()
|
||||||
@@ -244,7 +248,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
|
|
||||||
flash(f"{len(uuids)} watches were tagged")
|
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'])
|
@ui_blueprint.route("/share-url/<string:uuid>", methods=['GET'])
|
||||||
@@ -296,6 +300,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
|
|||||||
logger.error(f"Error sharing -{str(e)}")
|
logger.error(f"Error sharing -{str(e)}")
|
||||||
flash(f"Could not share, something went wrong while communicating with the share server - {str(e)}", 'error')
|
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
|
return ui_blueprint
|
||||||
@@ -19,6 +19,20 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')):
|
if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def levenshtein_ratio_recent_history(watch):
|
||||||
|
try:
|
||||||
|
from Levenshtein import ratio, distance
|
||||||
|
k = list(watch.history.keys())
|
||||||
|
if len(k) >= 2:
|
||||||
|
a = watch.get_history_snapshot(timestamp=k[0])
|
||||||
|
b = watch.get_history_snapshot(timestamp=k[1])
|
||||||
|
distance = distance(a, b)
|
||||||
|
return distance
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Unable to calc similarity", e)
|
||||||
|
return "Unable to calc similarity"
|
||||||
|
return ''
|
||||||
|
|
||||||
@edit_blueprint.route("/edit/<string:uuid>", methods=['GET', 'POST'])
|
@edit_blueprint.route("/edit/<string:uuid>", methods=['GET', 'POST'])
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
# https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists
|
# https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists
|
||||||
@@ -32,14 +46,14 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
# More for testing, possible to return the first/only
|
# More for testing, possible to return the first/only
|
||||||
if not datastore.data['watching'].keys():
|
if not datastore.data['watching'].keys():
|
||||||
flash("No watches to edit", "error")
|
flash("No watches to edit", "error")
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('watchlist.index'))
|
||||||
|
|
||||||
if uuid == 'first':
|
if uuid == 'first':
|
||||||
uuid = list(datastore.data['watching'].keys()).pop()
|
uuid = list(datastore.data['watching'].keys()).pop()
|
||||||
|
|
||||||
if not uuid in datastore.data['watching']:
|
if not uuid in datastore.data['watching']:
|
||||||
flash("No watch with the UUID %s found." % (uuid), "error")
|
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')
|
switch_processor = request.args.get('switch_processor')
|
||||||
if switch_processor:
|
if switch_processor:
|
||||||
@@ -66,7 +80,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)
|
processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None)
|
||||||
if not processor_classes:
|
if not processor_classes:
|
||||||
flash(f"Cannot load the edit form for processor/plugin '{processor_classes[1]}', plugin missing?", 'error')
|
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])
|
parent_module = processors.get_parent_module(processor_classes[0])
|
||||||
|
|
||||||
@@ -153,7 +167,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
extra_update_obj['tags'] = form.data.get('tags')
|
extra_update_obj['tags'] = form.data.get('tags')
|
||||||
else:
|
else:
|
||||||
for t in form.data.get('tags').split(','):
|
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
|
extra_update_obj['tags'] = tag_uuids
|
||||||
|
|
||||||
datastore.data['watching'][uuid].update(form.data)
|
datastore.data['watching'][uuid].update(form.data)
|
||||||
@@ -207,7 +221,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
if request.args.get("next") and request.args.get("next") == 'diff':
|
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('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:
|
else:
|
||||||
if request.method == 'POST' and not form.validate():
|
if request.method == 'POST' and not form.validate():
|
||||||
@@ -247,14 +261,15 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False,
|
'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False,
|
||||||
'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
|
'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
|
||||||
'has_special_tag_options': _watch_has_tag_options_set(watch=watch),
|
'has_special_tag_options': _watch_has_tag_options_set(watch=watch),
|
||||||
'watch_uses_webdriver': watch_uses_webdriver,
|
|
||||||
'jq_support': jq_support,
|
'jq_support': jq_support,
|
||||||
|
'lev_info': levenshtein_ratio_recent_history(watch),
|
||||||
'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False),
|
'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False),
|
||||||
'settings_application': datastore.data['settings']['application'],
|
'settings_application': datastore.data['settings']['application'],
|
||||||
'timezone_default_config': datastore.data['settings']['application'].get('timezone'),
|
'timezone_default_config': datastore.data['settings']['application'].get('timezone'),
|
||||||
'using_global_webdriver_wait': not default['webdriver_delay'],
|
'using_global_webdriver_wait': not default['webdriver_delay'],
|
||||||
'uuid': uuid,
|
'uuid': uuid,
|
||||||
'watch': watch
|
'watch': watch,
|
||||||
|
'watch_uses_webdriver': watch_uses_webdriver,
|
||||||
}
|
}
|
||||||
|
|
||||||
included_content = None
|
included_content = None
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
|
|
||||||
# Watch_uuid could be unset in the case it`s used in tag editor, global settings
|
# Watch_uuid could be unset in the case it`s used in tag editor, global settings
|
||||||
import apprise
|
import apprise
|
||||||
from changedetectionio.apprise_asset import asset
|
from changedetectionio.notification.handler import process_notification
|
||||||
apobj = apprise.Apprise(asset=asset)
|
from changedetectionio.notification.apprise_plugin.assets import apprise_asset
|
||||||
|
|
||||||
|
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
|
||||||
|
|
||||||
|
apobj = apprise.Apprise(asset=apprise_asset)
|
||||||
|
|
||||||
# so that the custom endpoints are registered
|
|
||||||
from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
|
|
||||||
is_global_settings_form = request.args.get('mode', '') == 'global-settings'
|
is_global_settings_form = request.args.get('mode', '') == 'global-settings'
|
||||||
is_group_settings_form = request.args.get('mode', '') == 'group-settings'
|
is_group_settings_form = request.args.get('mode', '') == 'group-settings'
|
||||||
|
|
||||||
@@ -90,7 +92,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
|
|
||||||
n_object['as_async'] = False
|
n_object['as_async'] = False
|
||||||
n_object.update(watch.extra_notification_token_values())
|
n_object.update(watch.extra_notification_token_values())
|
||||||
from changedetectionio.notification import process_notification
|
|
||||||
sent_obj = process_notification(n_object, datastore)
|
sent_obj = process_notification(n_object, datastore)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div class="pure-control-group">
|
<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
|
>Cancel</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
watch = datastore.data['watching'][uuid]
|
watch = datastore.data['watching'][uuid]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
flash("No history found for the specified link, bad link?", "error")
|
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'
|
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
|
||||||
extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
|
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]
|
watch = datastore.data['watching'][uuid]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
flash("No history found for the specified link, bad link?", "error")
|
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
|
# For submission of requesting an extract
|
||||||
extract_form = forms.extractDataForm(request.form)
|
extract_form = forms.extractDataForm(request.form)
|
||||||
@@ -119,7 +119,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
|
|
||||||
if len(dates) < 2:
|
if len(dates) < 2:
|
||||||
flash("Not enough saved change detection snapshots to produce a report.", "error")
|
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
|
# Save the current newest history as the most recently viewed
|
||||||
datastore.set_last_viewed(uuid, time.time())
|
datastore.set_last_viewed(uuid, time.time())
|
||||||
@@ -196,7 +196,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
if not form.validate():
|
if not form.validate():
|
||||||
for widget, l in form.errors.items():
|
for widget, l in form.errors.items():
|
||||||
flash(','.join(l), 'error')
|
flash(','.join(l), 'error')
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('watchlist.index'))
|
||||||
|
|
||||||
url = request.form.get('url').strip()
|
url = request.form.get('url').strip()
|
||||||
if datastore.url_exists(url):
|
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}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
|
||||||
flash("Watch added.")
|
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
|
return views_blueprint
|
||||||
111
changedetectionio/blueprint/watchlist/__init__.py
Normal file
111
changedetectionio/blueprint/watchlist/__init__.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session
|
||||||
|
from flask_login import current_user
|
||||||
|
from flask_paginate import Pagination, get_page_parameter
|
||||||
|
|
||||||
|
from changedetectionio import forms
|
||||||
|
from changedetectionio.store import ChangeDetectionStore
|
||||||
|
from changedetectionio.auth_decorator import login_optionally_required
|
||||||
|
|
||||||
|
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
|
||||||
|
watchlist_blueprint = Blueprint('watchlist', __name__, template_folder="templates")
|
||||||
|
|
||||||
|
@watchlist_blueprint.route("/", methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
|
def index():
|
||||||
|
active_tag_req = request.args.get('tag', '').lower().strip()
|
||||||
|
active_tag_uuid = active_tag = None
|
||||||
|
|
||||||
|
# Be sure limit_tag is a uuid
|
||||||
|
if active_tag_req:
|
||||||
|
for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
|
||||||
|
if active_tag_req == tag.get('title', '').lower().strip() or active_tag_req == uuid:
|
||||||
|
active_tag = tag
|
||||||
|
active_tag_uuid = uuid
|
||||||
|
break
|
||||||
|
|
||||||
|
# Redirect for the old rss path which used the /?rss=true
|
||||||
|
if request.args.get('rss'):
|
||||||
|
return redirect(url_for('rss.feed', tag=active_tag_uuid))
|
||||||
|
|
||||||
|
op = request.args.get('op')
|
||||||
|
if op:
|
||||||
|
uuid = request.args.get('uuid')
|
||||||
|
if op == 'pause':
|
||||||
|
datastore.data['watching'][uuid].toggle_pause()
|
||||||
|
elif op == 'mute':
|
||||||
|
datastore.data['watching'][uuid].toggle_mute()
|
||||||
|
|
||||||
|
datastore.needs_write = True
|
||||||
|
return redirect(url_for('watchlist.index', tag = active_tag_uuid))
|
||||||
|
|
||||||
|
# Sort by last_changed and add the uuid which is usually the key..
|
||||||
|
sorted_watches = []
|
||||||
|
with_errors = request.args.get('with_errors') == "1"
|
||||||
|
errored_count = 0
|
||||||
|
search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
|
||||||
|
for uuid, watch in datastore.data['watching'].items():
|
||||||
|
if with_errors and not watch.get('last_error'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if active_tag_uuid and not active_tag_uuid in watch['tags']:
|
||||||
|
continue
|
||||||
|
if watch.get('last_error'):
|
||||||
|
errored_count += 1
|
||||||
|
|
||||||
|
if search_q:
|
||||||
|
if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
|
||||||
|
sorted_watches.append(watch)
|
||||||
|
elif watch.get('last_error') and search_q in watch.get('last_error').lower():
|
||||||
|
sorted_watches.append(watch)
|
||||||
|
else:
|
||||||
|
sorted_watches.append(watch)
|
||||||
|
|
||||||
|
form = forms.quickWatchForm(request.form)
|
||||||
|
page = request.args.get(get_page_parameter(), type=int, default=1)
|
||||||
|
total_count = len(sorted_watches)
|
||||||
|
|
||||||
|
pagination = Pagination(page=page,
|
||||||
|
total=total_count,
|
||||||
|
per_page=datastore.data['settings']['application'].get('pager_size', 50), css_framework="semantic")
|
||||||
|
|
||||||
|
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
|
||||||
|
output = render_template(
|
||||||
|
"watch-overview.html",
|
||||||
|
active_tag=active_tag,
|
||||||
|
active_tag_uuid=active_tag_uuid,
|
||||||
|
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
|
||||||
|
datastore=datastore,
|
||||||
|
errored_count=errored_count,
|
||||||
|
form=form,
|
||||||
|
guid=datastore.data['app_guid'],
|
||||||
|
has_proxies=datastore.proxy_list,
|
||||||
|
has_unviewed=datastore.has_unviewed,
|
||||||
|
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
|
||||||
|
now_time_server=time.time(),
|
||||||
|
pagination=pagination,
|
||||||
|
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
|
||||||
|
search_q=request.args.get('q', '').strip(),
|
||||||
|
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
|
||||||
|
sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
|
||||||
|
system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
|
||||||
|
tags=sorted_tags,
|
||||||
|
watches=sorted_watches
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.get('share-link'):
|
||||||
|
del(session['share-link'])
|
||||||
|
|
||||||
|
resp = make_response(output)
|
||||||
|
|
||||||
|
# The template can run on cookie or url query info
|
||||||
|
if request.args.get('sort'):
|
||||||
|
resp.set_cookie('sort', request.args.get('sort'))
|
||||||
|
if request.args.get('order'):
|
||||||
|
resp.set_cookie('order', request.args.get('order'))
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return watchlist_blueprint
|
||||||
@@ -3,7 +3,16 @@
|
|||||||
{% from '_helpers.html' import render_simple_field, render_field, render_nolabel_field, sort_by_title %}
|
{% 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='jquery-3.6.0.min.js')}}"></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></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">
|
<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">
|
<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 %}
|
{% endif %}
|
||||||
{% if search_q %}<div id="search-result-info">Searching "<strong><i>{{search_q}}</i></strong>"</div>{% endif %}
|
{% if search_q %}<div id="search-result-info">Searching "<strong><i>{{search_q}}</i></strong>"</div>{% endif %}
|
||||||
<div>
|
<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 -->
|
<!-- tag list -->
|
||||||
{% for uuid, tag in tags %}
|
{% for uuid, tag in tags %}
|
||||||
{% if tag != "" %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -72,14 +81,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
{% set link_order = "desc" if sort_order == 'asc' else "asc" %}
|
{% set link_order = "desc" if sort_order == 'asc' else "asc" %}
|
||||||
{% set arrow_span = "" %}
|
{% 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 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 %}
|
{% if any_has_restock_price_processor %}
|
||||||
<th>Restock & Price</th>
|
<th>Restock & Price</th>
|
||||||
{% endif %}
|
{% 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_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('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_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>
|
<th class="empty-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -92,7 +101,7 @@
|
|||||||
{% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) %}
|
{% 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 }}"
|
<tr id="{{ watch.uuid }}"
|
||||||
class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} processor-{{ watch['processor'] }}
|
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 %}
|
{% 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 watch.paused is defined and watch.paused != False %}paused{% endif %}
|
||||||
{% if is_unviewed %}unviewed{% 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.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 checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td>
|
||||||
<td class="inline watch-controls">
|
<td class="inline watch-controls">
|
||||||
{% if not watch.paused %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% set mute_label = 'UnMute notification' if watch.notification_muted else 'Mute notification' %}
|
{% 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>
|
||||||
<td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}
|
<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>
|
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a>
|
||||||
@@ -119,7 +130,7 @@
|
|||||||
or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
|
or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
|
||||||
or "extra_browser_" in watch.get_fetch_backend
|
or "extra_browser_" in watch.get_fetch_backend
|
||||||
%}
|
%}
|
||||||
<img class="status-icon" src="{{url_for('static_content', group='images', filename='Google-Chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" >
|
<img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" >
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{%if watch.is_pdf %}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" title="Converting PDF to text" >{% endif %}
|
{%if watch.is_pdf %}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" title="Converting PDF to text" >{% endif %}
|
||||||
@@ -178,7 +189,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% 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 %}
|
<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}}
|
{{watch.last_changed|format_timestamp_timeago}}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -191,15 +209,18 @@
|
|||||||
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a>
|
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a>
|
||||||
{% if watch.history_n >= 2 %}
|
{% if watch.history_n >= 2 %}
|
||||||
|
|
||||||
|
{% set open_diff_in_new_tab = datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') %}
|
||||||
|
{% set target_attr = ' target="' ~ watch.uuid ~ '"' if open_diff_in_new_tab else '' %}
|
||||||
|
|
||||||
{% if is_unviewed %}
|
{% if is_unviewed %}
|
||||||
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
|
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" {{target_attr}} class="pure-button pure-button-primary diff-link">History</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
|
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary diff-link">History</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if watch.history_n == 1 or (watch.history_n ==0 and watch.error_text_ctime )%}
|
{% if watch.history_n == 1 or (watch.history_n ==0 and watch.error_text_ctime )%}
|
||||||
<a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary">Preview</a>
|
<a href="{{ url_for('ui.ui_views.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary">Preview</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -210,7 +231,7 @@
|
|||||||
<ul id="post-list-buttons">
|
<ul id="post-list-buttons">
|
||||||
{% if errored_count %}
|
{% if errored_count %}
|
||||||
<li>
|
<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>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if has_unviewed %}
|
{% if has_unviewed %}
|
||||||
@@ -223,7 +244,7 @@
|
|||||||
all {% if active_tag_uuid %} in "{{active_tag.title}}"{%endif%}</a>
|
all {% if active_tag_uuid %} in "{{active_tag.title}}"{%endif%}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='Generic_Feed-icon.svg')}}" height="15"></a>
|
<a href="{{ url_for('rss.feed', tag=active_tag_uuid, token=app_rss_token)}}"><img alt="RSS Feed" id="feed-icon" src="{{url_for('static_content', group='images', filename='generic_feed-icon.svg')}}" height="15"></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{ pagination.links }}
|
{{ pagination.links }}
|
||||||
@@ -8,7 +8,7 @@ from . import default_plugin
|
|||||||
|
|
||||||
# List of all supported JSON Logic operators
|
# List of all supported JSON Logic operators
|
||||||
operator_choices = [
|
operator_choices = [
|
||||||
(None, "Choose one"),
|
(None, "Choose one - Operator"),
|
||||||
(">", "Greater Than"),
|
(">", "Greater Than"),
|
||||||
("<", "Less Than"),
|
("<", "Less Than"),
|
||||||
(">=", "Greater Than or Equal To"),
|
(">=", "Greater Than or Equal To"),
|
||||||
@@ -21,7 +21,7 @@ operator_choices = [
|
|||||||
|
|
||||||
# Fields available in the rules
|
# Fields available in the rules
|
||||||
field_choices = [
|
field_choices = [
|
||||||
(None, "Choose one"),
|
(None, "Choose one - Field"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# The data we will feed the JSON Rules to see if it passes the test/conditions or not
|
# The data we will feed the JSON Rules to see if it passes the test/conditions or not
|
||||||
@@ -96,7 +96,7 @@ def execute_ruleset_against_all_plugins(current_watch_uuid: str, application_dat
|
|||||||
|
|
||||||
ruleset_settings = application_datastruct['watching'].get(current_watch_uuid)
|
ruleset_settings = application_datastruct['watching'].get(current_watch_uuid)
|
||||||
|
|
||||||
if ruleset_settings.get("conditions"):
|
if ruleset_settings and ruleset_settings.get("conditions"):
|
||||||
logic_operator = "and" if ruleset_settings.get("conditions_match_logic", "ALL") == "ALL" else "or"
|
logic_operator = "and" if ruleset_settings.get("conditions_match_logic", "ALL") == "ALL" else "or"
|
||||||
complete_rules = filter_complete_rules(ruleset_settings['conditions'])
|
complete_rules = filter_complete_rules(ruleset_settings['conditions'])
|
||||||
if complete_rules:
|
if complete_rules:
|
||||||
@@ -116,8 +116,7 @@ def execute_ruleset_against_all_plugins(current_watch_uuid: str, application_dat
|
|||||||
if not jsonLogic(logic=ruleset, data=EXECUTE_DATA, operations=CUSTOM_OPERATIONS):
|
if not jsonLogic(logic=ruleset, data=EXECUTE_DATA, operations=CUSTOM_OPERATIONS):
|
||||||
result = False
|
result = False
|
||||||
|
|
||||||
return result
|
return {'executed_data': EXECUTE_DATA, 'result': result}
|
||||||
|
|
||||||
|
|
||||||
# Load plugins dynamically
|
# Load plugins dynamically
|
||||||
for plugin in plugin_manager.get_plugins():
|
for plugin in plugin_manager.get_plugins():
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ def construct_blueprint(datastore):
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'result': result,
|
'result': result.get('result'),
|
||||||
|
'data': result.get('executed_data'),
|
||||||
'message': 'Condition passes' if result else 'Condition does not pass'
|
'message': 'Condition passes' if result else 'Condition does not pass'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ConditionFormRow(Form):
|
|||||||
validators=[validators.Optional()]
|
validators=[validators.Optional()]
|
||||||
)
|
)
|
||||||
|
|
||||||
value = StringField("Value", validators=[validators.Optional()])
|
value = StringField("Value", validators=[validators.Optional()], render_kw={"placeholder": "A value"})
|
||||||
|
|
||||||
def validate(self, extra_validators=None):
|
def validate(self, extra_validators=None):
|
||||||
# First, run the default validators
|
# First, run the default validators
|
||||||
|
|||||||
@@ -7,11 +7,29 @@ import os
|
|||||||
# Visual Selector scraper - 'Button' is there because some sites have <button>OUT OF STOCK</button>.
|
# Visual Selector scraper - 'Button' is there because some sites have <button>OUT OF STOCK</button>.
|
||||||
visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary,button'
|
visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary,button'
|
||||||
|
|
||||||
|
SCREENSHOT_MAX_HEIGHT_DEFAULT = 20000
|
||||||
|
SCREENSHOT_DEFAULT_QUALITY = 40
|
||||||
|
|
||||||
|
# Maximum total height for the final image (When in stitch mode).
|
||||||
|
# We limit this to 16000px due to the huge amount of RAM that was being used
|
||||||
|
# Example: 16000 × 1400 × 3 = 67,200,000 bytes ≈ 64.1 MB (not including buffers in PIL etc)
|
||||||
|
SCREENSHOT_MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
|
||||||
|
|
||||||
|
# The size at which we will switch to stitching method, when below this (and
|
||||||
|
# MAX_TOTAL_HEIGHT which can be set by a user) we will use the default
|
||||||
|
# screenshot method.
|
||||||
|
SCREENSHOT_SIZE_STITCH_THRESHOLD = 8000
|
||||||
|
|
||||||
# available_fetchers() will scan this implementation looking for anything starting with html_
|
# available_fetchers() will scan this implementation looking for anything starting with html_
|
||||||
# this information is used in the form selections
|
# this information is used in the form selections
|
||||||
from changedetectionio.content_fetchers.requests import fetcher as html_requests
|
from changedetectionio.content_fetchers.requests import fetcher as html_requests
|
||||||
|
|
||||||
|
|
||||||
|
import importlib.resources
|
||||||
|
XPATH_ELEMENT_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
|
||||||
|
INSTOCK_DATA_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
def available_fetchers():
|
def available_fetchers():
|
||||||
# See the if statement at the bottom of this file for how we switch between playwright and webdriver
|
# See the if statement at the bottom of this file for how we switch between playwright and webdriver
|
||||||
import inspect
|
import inspect
|
||||||
|
|||||||
@@ -63,11 +63,6 @@ class Fetcher():
|
|||||||
# Time ONTOP of the system defined env minimum time
|
# Time ONTOP of the system defined env minimum time
|
||||||
render_extract_delay = 0
|
render_extract_delay = 0
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
import importlib.resources
|
|
||||||
self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
|
|
||||||
self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_error(self):
|
def get_error(self):
|
||||||
return self.error
|
return self.error
|
||||||
@@ -87,7 +82,7 @@ class Fetcher():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def quit(self):
|
def quit(self, watch=None):
|
||||||
return
|
return
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -143,6 +138,7 @@ class Fetcher():
|
|||||||
logger.debug(f">> Iterating check - browser Step n {step_n} - {step['operation']}...")
|
logger.debug(f">> Iterating check - browser Step n {step_n} - {step['operation']}...")
|
||||||
self.screenshot_step("before-" + str(step_n))
|
self.screenshot_step("before-" + str(step_n))
|
||||||
self.save_step_html("before-" + str(step_n))
|
self.save_step_html("before-" + str(step_n))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
optional_value = step['optional_value']
|
optional_value = step['optional_value']
|
||||||
selector = step['selector']
|
selector = step['selector']
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
|
|
||||||
# Pages with a vertical height longer than this will use the 'stitch together' method.
|
|
||||||
|
|
||||||
# - Many GPUs have a max texture size of 16384x16384px (or lower on older devices).
|
|
||||||
# - If a page is taller than ~8000–10000px, it risks exceeding GPU memory limits.
|
|
||||||
# - This is especially important on headless Chromium, where Playwright may fail to allocate a massive full-page buffer.
|
|
||||||
|
|
||||||
|
|
||||||
# The size at which we will switch to stitching method
|
|
||||||
SCREENSHOT_SIZE_STITCH_THRESHOLD=8000
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
def capture_stitched_together_full_page(page):
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
MAX_TOTAL_HEIGHT = SCREENSHOT_SIZE_STITCH_THRESHOLD*4 # Maximum total height for the final image (When in stitch mode)
|
|
||||||
MAX_CHUNK_HEIGHT = 4000 # Height per screenshot chunk
|
|
||||||
WARNING_TEXT_HEIGHT = 20 # Height of the warning text overlay
|
|
||||||
|
|
||||||
# Save the original viewport size
|
|
||||||
original_viewport = page.viewport_size
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
viewport = page.viewport_size
|
|
||||||
page_height = page.evaluate("document.documentElement.scrollHeight")
|
|
||||||
|
|
||||||
# Limit the total capture height
|
|
||||||
capture_height = min(page_height, MAX_TOTAL_HEIGHT)
|
|
||||||
|
|
||||||
images = []
|
|
||||||
total_captured_height = 0
|
|
||||||
|
|
||||||
for offset in range(0, capture_height, MAX_CHUNK_HEIGHT):
|
|
||||||
# Ensure we do not exceed the total height limit
|
|
||||||
chunk_height = min(MAX_CHUNK_HEIGHT, MAX_TOTAL_HEIGHT - total_captured_height)
|
|
||||||
|
|
||||||
# Adjust viewport size for this chunk
|
|
||||||
page.set_viewport_size({"width": viewport["width"], "height": chunk_height})
|
|
||||||
|
|
||||||
# Scroll to the correct position
|
|
||||||
page.evaluate(f"window.scrollTo(0, {offset})")
|
|
||||||
|
|
||||||
# Capture screenshot chunk
|
|
||||||
screenshot_bytes = page.screenshot(type='jpeg', quality=int(os.getenv("SCREENSHOT_QUALITY", 30)))
|
|
||||||
images.append(Image.open(io.BytesIO(screenshot_bytes)))
|
|
||||||
|
|
||||||
total_captured_height += chunk_height
|
|
||||||
|
|
||||||
# Stop if we reached the maximum total height
|
|
||||||
if total_captured_height >= MAX_TOTAL_HEIGHT:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Create the final stitched image
|
|
||||||
stitched_image = Image.new('RGB', (viewport["width"], total_captured_height))
|
|
||||||
y_offset = 0
|
|
||||||
|
|
||||||
# Stitch the screenshot chunks together
|
|
||||||
for img in images:
|
|
||||||
stitched_image.paste(img, (0, y_offset))
|
|
||||||
y_offset += img.height
|
|
||||||
|
|
||||||
logger.debug(f"Screenshot stitched together in {time.time()-now:.2f}s")
|
|
||||||
|
|
||||||
# Overlay warning text if the screenshot was trimmed
|
|
||||||
if page_height > MAX_TOTAL_HEIGHT:
|
|
||||||
draw = ImageDraw.Draw(stitched_image)
|
|
||||||
warning_text = f"WARNING: Screenshot was {page_height}px but trimmed to {MAX_TOTAL_HEIGHT}px because it was too long"
|
|
||||||
|
|
||||||
# Load font (default system font if Arial is unavailable)
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype("arial.ttf", WARNING_TEXT_HEIGHT) # Arial (Windows/Mac)
|
|
||||||
except IOError:
|
|
||||||
font = ImageFont.load_default() # Default font if Arial not found
|
|
||||||
|
|
||||||
# Get text bounding box (correct method for newer Pillow versions)
|
|
||||||
text_bbox = draw.textbbox((0, 0), warning_text, font=font)
|
|
||||||
text_width = text_bbox[2] - text_bbox[0] # Calculate text width
|
|
||||||
text_height = text_bbox[3] - text_bbox[1] # Calculate text height
|
|
||||||
|
|
||||||
# Define background rectangle (top of the image)
|
|
||||||
draw.rectangle([(0, 0), (viewport["width"], WARNING_TEXT_HEIGHT)], fill="white")
|
|
||||||
|
|
||||||
# Center text horizontally within the warning area
|
|
||||||
text_x = (viewport["width"] - text_width) // 2
|
|
||||||
text_y = (WARNING_TEXT_HEIGHT - text_height) // 2
|
|
||||||
|
|
||||||
# Draw the warning text in red
|
|
||||||
draw.text((text_x, text_y), warning_text, fill="red", font=font)
|
|
||||||
|
|
||||||
# Save or return the final image
|
|
||||||
output = io.BytesIO()
|
|
||||||
stitched_image.save(output, format="JPEG", quality=int(os.getenv("SCREENSHOT_QUALITY", 30)))
|
|
||||||
screenshot = output.getvalue()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Restore the original viewport size
|
|
||||||
page.set_viewport_size(original_viewport)
|
|
||||||
|
|
||||||
return screenshot
|
|
||||||
@@ -4,10 +4,76 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from changedetectionio.content_fetchers.helpers import capture_stitched_together_full_page, SCREENSHOT_SIZE_STITCH_THRESHOLD
|
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
|
||||||
|
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_MAX_TOTAL_HEIGHT, XPATH_ELEMENT_JS, INSTOCK_DATA_JS
|
||||||
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
|
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
|
||||||
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable
|
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, ScreenshotUnavailable
|
||||||
|
|
||||||
|
def capture_full_page(page):
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from multiprocessing import Process, Pipe
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
page_height = page.evaluate("document.documentElement.scrollHeight")
|
||||||
|
page_width = page.evaluate("document.documentElement.scrollWidth")
|
||||||
|
original_viewport = page.viewport_size
|
||||||
|
|
||||||
|
logger.debug(f"Playwright viewport size {page.viewport_size} page height {page_height} page width {page_width}")
|
||||||
|
|
||||||
|
# Use an approach similar to puppeteer: set a larger viewport and take screenshots in chunks
|
||||||
|
step_size = SCREENSHOT_SIZE_STITCH_THRESHOLD # Size that won't cause GPU to overflow
|
||||||
|
screenshot_chunks = []
|
||||||
|
y = 0
|
||||||
|
|
||||||
|
if page_height > page.viewport_size['height']:
|
||||||
|
if page_height < step_size:
|
||||||
|
step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
|
||||||
|
logger.debug(f"Setting bigger viewport to step through large page width W{page.viewport_size['width']}xH{step_size} because page_height > viewport_size")
|
||||||
|
# Set viewport to a larger size to capture more content at once
|
||||||
|
page.set_viewport_size({'width': page.viewport_size['width'], 'height': step_size})
|
||||||
|
|
||||||
|
# Capture screenshots in chunks up to the max total height
|
||||||
|
while y < min(page_height, SCREENSHOT_MAX_TOTAL_HEIGHT):
|
||||||
|
page.request_gc()
|
||||||
|
page.evaluate(f"window.scrollTo(0, {y})")
|
||||||
|
page.request_gc()
|
||||||
|
screenshot_chunks.append(page.screenshot(
|
||||||
|
type="jpeg",
|
||||||
|
full_page=False,
|
||||||
|
quality=int(os.getenv("SCREENSHOT_QUALITY", 72))
|
||||||
|
))
|
||||||
|
y += step_size
|
||||||
|
page.request_gc()
|
||||||
|
|
||||||
|
# Restore original viewport size
|
||||||
|
page.set_viewport_size({'width': original_viewport['width'], 'height': original_viewport['height']})
|
||||||
|
|
||||||
|
# If we have multiple chunks, stitch them together
|
||||||
|
if len(screenshot_chunks) > 1:
|
||||||
|
from changedetectionio.content_fetchers.screenshot_handler import stitch_images_worker
|
||||||
|
logger.debug(f"Screenshot stitching {len(screenshot_chunks)} chunks together")
|
||||||
|
parent_conn, child_conn = Pipe()
|
||||||
|
p = Process(target=stitch_images_worker, args=(child_conn, screenshot_chunks, page_height, SCREENSHOT_MAX_TOTAL_HEIGHT))
|
||||||
|
p.start()
|
||||||
|
screenshot = parent_conn.recv_bytes()
|
||||||
|
p.join()
|
||||||
|
logger.debug(
|
||||||
|
f"Screenshot (chunked/stitched) - Page height: {page_height} Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT} - Stitched together in {time.time() - start:.2f}s")
|
||||||
|
# Explicit cleanup
|
||||||
|
del screenshot_chunks
|
||||||
|
del p
|
||||||
|
del parent_conn, child_conn
|
||||||
|
screenshot_chunks = None
|
||||||
|
return screenshot
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Screenshot Page height: {page_height} Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT} - Stitched together in {time.time() - start:.2f}s")
|
||||||
|
|
||||||
|
return screenshot_chunks[0]
|
||||||
|
|
||||||
|
|
||||||
class fetcher(Fetcher):
|
class fetcher(Fetcher):
|
||||||
fetcher_description = "Playwright {}/Javascript".format(
|
fetcher_description = "Playwright {}/Javascript".format(
|
||||||
os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize()
|
os.getenv("PLAYWRIGHT_BROWSER_TYPE", 'chromium').capitalize()
|
||||||
@@ -60,7 +126,8 @@ class fetcher(Fetcher):
|
|||||||
|
|
||||||
def screenshot_step(self, step_n=''):
|
def screenshot_step(self, step_n=''):
|
||||||
super().screenshot_step(step_n=step_n)
|
super().screenshot_step(step_n=step_n)
|
||||||
screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("SCREENSHOT_QUALITY", 72)))
|
screenshot = capture_full_page(page=self.page)
|
||||||
|
|
||||||
|
|
||||||
if self.browser_steps_screenshot_path is not None:
|
if self.browser_steps_screenshot_path is not None:
|
||||||
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
|
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
|
||||||
@@ -89,7 +156,6 @@ class fetcher(Fetcher):
|
|||||||
|
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
import playwright._impl._errors
|
import playwright._impl._errors
|
||||||
from changedetectionio.content_fetchers import visualselector_xpath_selectors
|
|
||||||
import time
|
import time
|
||||||
self.delete_browser_steps_screenshots()
|
self.delete_browser_steps_screenshots()
|
||||||
response = None
|
response = None
|
||||||
@@ -164,9 +230,7 @@ class fetcher(Fetcher):
|
|||||||
raise PageUnloadable(url=url, status_code=None, message=str(e))
|
raise PageUnloadable(url=url, status_code=None, message=str(e))
|
||||||
|
|
||||||
if self.status_code != 200 and not ignore_status_codes:
|
if self.status_code != 200 and not ignore_status_codes:
|
||||||
screenshot = self.page.screenshot(type='jpeg', full_page=True,
|
screenshot = capture_full_page(self.page)
|
||||||
quality=int(os.getenv("SCREENSHOT_QUALITY", 72)))
|
|
||||||
|
|
||||||
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
|
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
|
||||||
|
|
||||||
if not empty_pages_are_a_change and len(self.page.content().strip()) == 0:
|
if not empty_pages_are_a_change and len(self.page.content().strip()) == 0:
|
||||||
@@ -187,13 +251,23 @@ class fetcher(Fetcher):
|
|||||||
self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
|
self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
|
||||||
else:
|
else:
|
||||||
self.page.evaluate("var include_filters=''")
|
self.page.evaluate("var include_filters=''")
|
||||||
|
self.page.request_gc()
|
||||||
|
|
||||||
self.xpath_data = self.page.evaluate(
|
# request_gc before and after evaluate to free up memory
|
||||||
"async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}")
|
# @todo browsersteps etc
|
||||||
self.instock_data = self.page.evaluate("async () => {" + self.instock_data_js + "}")
|
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
|
||||||
|
self.xpath_data = self.page.evaluate(XPATH_ELEMENT_JS, {
|
||||||
|
"visualselector_xpath_selectors": visualselector_xpath_selectors,
|
||||||
|
"max_height": MAX_TOTAL_HEIGHT
|
||||||
|
})
|
||||||
|
self.page.request_gc()
|
||||||
|
|
||||||
|
self.instock_data = self.page.evaluate(INSTOCK_DATA_JS)
|
||||||
|
self.page.request_gc()
|
||||||
|
|
||||||
self.content = self.page.content()
|
self.content = self.page.content()
|
||||||
logger.debug(f"Time to scrape xpath element data in browser {time.time() - now:.2f}s")
|
self.page.request_gc()
|
||||||
|
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
|
||||||
|
|
||||||
# Bug 3 in Playwright screenshot handling
|
# Bug 3 in Playwright screenshot handling
|
||||||
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
|
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
|
||||||
@@ -204,18 +278,41 @@ class fetcher(Fetcher):
|
|||||||
# acceptable screenshot quality here
|
# acceptable screenshot quality here
|
||||||
try:
|
try:
|
||||||
# The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage
|
# The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage
|
||||||
full_height = self.page.evaluate("document.documentElement.scrollHeight")
|
self.screenshot = capture_full_page(page=self.page)
|
||||||
|
|
||||||
if full_height >= SCREENSHOT_SIZE_STITCH_THRESHOLD:
|
|
||||||
logger.warning(
|
|
||||||
f"Page full Height: {full_height}px longer than {SCREENSHOT_SIZE_STITCH_THRESHOLD}px, using 'stitched screenshot method'.")
|
|
||||||
self.screenshot = capture_stitched_together_full_page(self.page)
|
|
||||||
else:
|
|
||||||
self.screenshot = self.page.screenshot(type='jpeg', full_page=True, quality=int(os.getenv("SCREENSHOT_QUALITY", 30)))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# It's likely the screenshot was too long/big and something crashed
|
# It's likely the screenshot was too long/big and something crashed
|
||||||
raise ScreenshotUnavailable(url=url, status_code=self.status_code)
|
raise ScreenshotUnavailable(url=url, status_code=self.status_code)
|
||||||
finally:
|
finally:
|
||||||
|
# Request garbage collection one more time before closing
|
||||||
|
try:
|
||||||
|
self.page.request_gc()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clean up resources properly
|
||||||
|
try:
|
||||||
|
self.page.request_gc()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.page.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.page = None
|
||||||
|
|
||||||
|
try:
|
||||||
context.close()
|
context.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
context = None
|
||||||
|
|
||||||
|
try:
|
||||||
browser.close()
|
browser.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
browser = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,77 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, visualselector_xpath_selectors, \
|
||||||
|
SCREENSHOT_SIZE_STITCH_THRESHOLD, SCREENSHOT_DEFAULT_QUALITY, XPATH_ELEMENT_JS, INSTOCK_DATA_JS, \
|
||||||
|
SCREENSHOT_MAX_TOTAL_HEIGHT
|
||||||
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
|
from changedetectionio.content_fetchers.base import Fetcher, manage_user_agent
|
||||||
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, BrowserConnectError
|
from changedetectionio.content_fetchers.exceptions import PageUnloadable, Non200ErrorCodeReceived, EmptyReply, BrowserFetchTimedOut, \
|
||||||
|
BrowserConnectError
|
||||||
|
|
||||||
|
|
||||||
|
# Bug 3 in Playwright screenshot handling
|
||||||
|
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
|
||||||
|
|
||||||
|
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
|
||||||
|
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
|
||||||
|
# acceptable screenshot quality here
|
||||||
|
async def capture_full_page(page):
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from multiprocessing import Process, Pipe
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
page_height = await page.evaluate("document.documentElement.scrollHeight")
|
||||||
|
page_width = await page.evaluate("document.documentElement.scrollWidth")
|
||||||
|
original_viewport = page.viewport
|
||||||
|
|
||||||
|
logger.debug(f"Puppeteer viewport size {page.viewport} page height {page_height} page width {page_width}")
|
||||||
|
|
||||||
|
# Bug 3 in Playwright screenshot handling
|
||||||
|
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
|
||||||
|
# JPEG is better here because the screenshots can be very very large
|
||||||
|
|
||||||
|
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
|
||||||
|
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
|
||||||
|
# acceptable screenshot quality here
|
||||||
|
|
||||||
|
|
||||||
|
step_size = SCREENSHOT_SIZE_STITCH_THRESHOLD # Something that will not cause the GPU to overflow when taking the screenshot
|
||||||
|
screenshot_chunks = []
|
||||||
|
y = 0
|
||||||
|
if page_height > page.viewport['height']:
|
||||||
|
if page_height < step_size:
|
||||||
|
step_size = page_height # Incase page is bigger than default viewport but smaller than proposed step size
|
||||||
|
await page.setViewport({'width': page.viewport['width'], 'height': step_size})
|
||||||
|
|
||||||
|
while y < min(page_height, SCREENSHOT_MAX_TOTAL_HEIGHT):
|
||||||
|
await page.evaluate(f"window.scrollTo(0, {y})")
|
||||||
|
screenshot_chunks.append(await page.screenshot(type_='jpeg',
|
||||||
|
fullPage=False,
|
||||||
|
quality=int(os.getenv("SCREENSHOT_QUALITY", 72))))
|
||||||
|
y += step_size
|
||||||
|
|
||||||
|
await page.setViewport({'width': original_viewport['width'], 'height': original_viewport['height']})
|
||||||
|
|
||||||
|
if len(screenshot_chunks) > 1:
|
||||||
|
from changedetectionio.content_fetchers.screenshot_handler import stitch_images_worker
|
||||||
|
logger.debug(f"Screenshot stitching {len(screenshot_chunks)} chunks together")
|
||||||
|
parent_conn, child_conn = Pipe()
|
||||||
|
p = Process(target=stitch_images_worker, args=(child_conn, screenshot_chunks, page_height, SCREENSHOT_MAX_TOTAL_HEIGHT))
|
||||||
|
p.start()
|
||||||
|
screenshot = parent_conn.recv_bytes()
|
||||||
|
p.join()
|
||||||
|
logger.debug(
|
||||||
|
f"Screenshot (chunked/stitched) - Page height: {page_height} Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT} - Stitched together in {time.time() - start:.2f}s")
|
||||||
|
|
||||||
|
screenshot_chunks = None
|
||||||
|
return screenshot
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Screenshot Page height: {page_height} Capture height: {SCREENSHOT_MAX_TOTAL_HEIGHT} - Stitched together in {time.time() - start:.2f}s")
|
||||||
|
return screenshot_chunks[0]
|
||||||
|
|
||||||
|
|
||||||
class fetcher(Fetcher):
|
class fetcher(Fetcher):
|
||||||
fetcher_description = "Puppeteer/direct {}/Javascript".format(
|
fetcher_description = "Puppeteer/direct {}/Javascript".format(
|
||||||
@@ -79,7 +148,6 @@ class fetcher(Fetcher):
|
|||||||
empty_pages_are_a_change
|
empty_pages_are_a_change
|
||||||
):
|
):
|
||||||
|
|
||||||
from changedetectionio.content_fetchers import visualselector_xpath_selectors
|
|
||||||
self.delete_browser_steps_screenshots()
|
self.delete_browser_steps_screenshots()
|
||||||
extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
|
extra_wait = int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay
|
||||||
|
|
||||||
@@ -181,11 +249,10 @@ class fetcher(Fetcher):
|
|||||||
raise PageUnloadable(url=url, status_code=None, message=str(e))
|
raise PageUnloadable(url=url, status_code=None, message=str(e))
|
||||||
|
|
||||||
if self.status_code != 200 and not ignore_status_codes:
|
if self.status_code != 200 and not ignore_status_codes:
|
||||||
screenshot = await self.page.screenshot(type_='jpeg',
|
screenshot = await capture_full_page(page=self.page)
|
||||||
fullPage=True,
|
|
||||||
quality=int(os.getenv("SCREENSHOT_QUALITY", 72)))
|
|
||||||
|
|
||||||
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
|
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
|
||||||
|
|
||||||
content = await self.page.content
|
content = await self.page.content
|
||||||
|
|
||||||
if not empty_pages_are_a_change and len(content.strip()) == 0:
|
if not empty_pages_are_a_change and len(content.strip()) == 0:
|
||||||
@@ -203,41 +270,26 @@ class fetcher(Fetcher):
|
|||||||
|
|
||||||
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
|
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
|
||||||
# Setup the xPath/VisualSelector scraper
|
# Setup the xPath/VisualSelector scraper
|
||||||
if current_include_filters is not None:
|
if current_include_filters:
|
||||||
js = json.dumps(current_include_filters)
|
js = json.dumps(current_include_filters)
|
||||||
await self.page.evaluate(f"var include_filters={js}")
|
await self.page.evaluate(f"var include_filters={js}")
|
||||||
else:
|
else:
|
||||||
await self.page.evaluate(f"var include_filters=''")
|
await self.page.evaluate(f"var include_filters=''")
|
||||||
|
|
||||||
self.xpath_data = await self.page.evaluate(
|
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
|
||||||
"async () => {" + self.xpath_element_js.replace('%ELEMENTS%', visualselector_xpath_selectors) + "}")
|
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
|
||||||
self.instock_data = await self.page.evaluate("async () => {" + self.instock_data_js + "}")
|
"visualselector_xpath_selectors": visualselector_xpath_selectors,
|
||||||
|
"max_height": MAX_TOTAL_HEIGHT
|
||||||
|
})
|
||||||
|
if not self.xpath_data:
|
||||||
|
raise Exception(f"Content Fetcher > xPath scraper failed. Please report this URL so we can fix it :)")
|
||||||
|
|
||||||
|
self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
|
||||||
|
|
||||||
self.content = await self.page.content
|
self.content = await self.page.content
|
||||||
# Bug 3 in Playwright screenshot handling
|
|
||||||
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
|
|
||||||
# JPEG is better here because the screenshots can be very very large
|
|
||||||
|
|
||||||
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
|
self.screenshot = await capture_full_page(page=self.page)
|
||||||
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
|
|
||||||
# acceptable screenshot quality here
|
|
||||||
try:
|
|
||||||
self.screenshot = await self.page.screenshot(type_='jpeg',
|
|
||||||
fullPage=True,
|
|
||||||
quality=int(os.getenv("SCREENSHOT_QUALITY", 72)))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error fetching screenshot")
|
|
||||||
# // May fail on very large pages with 'WARNING: tile memory limits exceeded, some content may not draw'
|
|
||||||
# // @ todo after text extract, we can place some overlay text with red background to say 'croppped'
|
|
||||||
logger.error('ERROR: content-fetcher page was maybe too large for a screenshot, reverting to viewport only screenshot')
|
|
||||||
try:
|
|
||||||
self.screenshot = await self.page.screenshot(type_='jpeg',
|
|
||||||
fullPage=False,
|
|
||||||
quality=int(os.getenv("SCREENSHOT_QUALITY", 72)))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('ERROR: Failed to get viewport-only reduced screenshot :(')
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
# It's good to log here in the case that the browser crashes on shutting down but we still get the data we need
|
# It's good to log here in the case that the browser crashes on shutting down but we still get the data we need
|
||||||
logger.success(f"Fetching '{url}' complete, closing page")
|
logger.success(f"Fetching '{url}' complete, closing page")
|
||||||
await self.page.close()
|
await self.page.close()
|
||||||
|
|||||||
@@ -96,3 +96,17 @@ class fetcher(Fetcher):
|
|||||||
|
|
||||||
|
|
||||||
self.raw_content = r.content
|
self.raw_content = r.content
|
||||||
|
|
||||||
|
def quit(self, watch=None):
|
||||||
|
|
||||||
|
# In case they switched to `requests` fetcher from something else
|
||||||
|
# Then the screenshot could be old, in any case, it's not used here.
|
||||||
|
# REMOVE_REQUESTS_OLD_SCREENSHOTS - Mainly used for testing
|
||||||
|
if strtobool(os.getenv("REMOVE_REQUESTS_OLD_SCREENSHOTS", 'true')):
|
||||||
|
screenshot = watch.get_screenshot()
|
||||||
|
if screenshot:
|
||||||
|
try:
|
||||||
|
os.unlink(screenshot)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to unlink screenshot: {screenshot} - {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
module.exports = async ({page, context}) => {
|
|
||||||
|
|
||||||
var {
|
|
||||||
url,
|
|
||||||
execute_js,
|
|
||||||
user_agent,
|
|
||||||
extra_wait_ms,
|
|
||||||
req_headers,
|
|
||||||
include_filters,
|
|
||||||
xpath_element_js,
|
|
||||||
screenshot_quality,
|
|
||||||
proxy_username,
|
|
||||||
proxy_password,
|
|
||||||
disk_cache_dir,
|
|
||||||
no_cache_list,
|
|
||||||
block_url_list,
|
|
||||||
} = context;
|
|
||||||
|
|
||||||
await page.setBypassCSP(true)
|
|
||||||
await page.setExtraHTTPHeaders(req_headers);
|
|
||||||
|
|
||||||
if (user_agent) {
|
|
||||||
await page.setUserAgent(user_agent);
|
|
||||||
}
|
|
||||||
// https://ourcodeworld.com/articles/read/1106/how-to-solve-puppeteer-timeouterror-navigation-timeout-of-30000-ms-exceeded
|
|
||||||
|
|
||||||
await page.setDefaultNavigationTimeout(0);
|
|
||||||
|
|
||||||
if (proxy_username) {
|
|
||||||
// Setting Proxy-Authentication header is deprecated, and doing so can trigger header change errors from Puppeteer
|
|
||||||
// https://github.com/puppeteer/puppeteer/issues/676 ?
|
|
||||||
// https://help.brightdata.com/hc/en-us/articles/12632549957649-Proxy-Manager-How-to-Guides#h_01HAKWR4Q0AFS8RZTNYWRDFJC2
|
|
||||||
// https://cri.dev/posts/2020-03-30-How-to-solve-Puppeteer-Chrome-Error-ERR_INVALID_ARGUMENT/
|
|
||||||
await page.authenticate({
|
|
||||||
username: proxy_username,
|
|
||||||
password: proxy_password
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.setViewport({
|
|
||||||
width: 1024,
|
|
||||||
height: 768,
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.setRequestInterception(true);
|
|
||||||
if (disk_cache_dir) {
|
|
||||||
console.log(">>>>>>>>>>>>>>> LOCAL DISK CACHE ENABLED <<<<<<<<<<<<<<<<<<<<<");
|
|
||||||
}
|
|
||||||
const fs = require('fs');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
function file_is_expired(file_path) {
|
|
||||||
if (!fs.existsSync(file_path)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
var stats = fs.statSync(file_path);
|
|
||||||
const now_date = new Date();
|
|
||||||
const expire_seconds = 300;
|
|
||||||
if ((now_date / 1000) - (stats.mtime.getTime() / 1000) > expire_seconds) {
|
|
||||||
console.log("CACHE EXPIRED: " + file_path);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
page.on('request', async (request) => {
|
|
||||||
// General blocking of requests that waste traffic
|
|
||||||
if (block_url_list.some(substring => request.url().toLowerCase().includes(substring))) return request.abort();
|
|
||||||
|
|
||||||
if (disk_cache_dir) {
|
|
||||||
const url = request.url();
|
|
||||||
const key = crypto.createHash('md5').update(url).digest("hex");
|
|
||||||
const dir_path = disk_cache_dir + key.slice(0, 1) + '/' + key.slice(1, 2) + '/' + key.slice(2, 3) + '/';
|
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/4482686/check-synchronously-if-file-directory-exists-in-node-js
|
|
||||||
|
|
||||||
if (fs.existsSync(dir_path + key)) {
|
|
||||||
console.log("* CACHE HIT , using - " + dir_path + key + " - " + url);
|
|
||||||
const cached_data = fs.readFileSync(dir_path + key);
|
|
||||||
// @todo headers can come from dir_path+key+".meta" json file
|
|
||||||
request.respond({
|
|
||||||
status: 200,
|
|
||||||
//contentType: 'text/html', //@todo
|
|
||||||
body: cached_data
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
request.continue();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
if (disk_cache_dir) {
|
|
||||||
page.on('response', async (response) => {
|
|
||||||
const url = response.url();
|
|
||||||
// Basic filtering for sane responses
|
|
||||||
if (response.request().method() != 'GET' || response.request().resourceType() == 'xhr' || response.request().resourceType() == 'document' || response.status() != 200) {
|
|
||||||
console.log("Skipping (not useful) - Status:" + response.status() + " Method:" + response.request().method() + " ResourceType:" + response.request().resourceType() + " " + url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (no_cache_list.some(substring => url.toLowerCase().includes(substring))) {
|
|
||||||
console.log("Skipping (no_cache_list) - " + url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.toLowerCase().includes('data:')) {
|
|
||||||
console.log("Skipping (embedded-data) - " + url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
response.buffer().then(buffer => {
|
|
||||||
if (buffer.length > 100) {
|
|
||||||
console.log("Cache - Saving " + response.request().method() + " - " + url + " - " + response.request().resourceType());
|
|
||||||
|
|
||||||
const key = crypto.createHash('md5').update(url).digest("hex");
|
|
||||||
const dir_path = disk_cache_dir + key.slice(0, 1) + '/' + key.slice(1, 2) + '/' + key.slice(2, 3) + '/';
|
|
||||||
|
|
||||||
if (!fs.existsSync(dir_path)) {
|
|
||||||
fs.mkdirSync(dir_path, {recursive: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(dir_path + key)) {
|
|
||||||
if (file_is_expired(dir_path + key)) {
|
|
||||||
fs.writeFileSync(dir_path + key, buffer);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(dir_path + key, buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = await page.goto(url, {
|
|
||||||
waitUntil: 'load'
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.waitForTimeout(extra_wait_ms);
|
|
||||||
|
|
||||||
if (execute_js) {
|
|
||||||
await page.evaluate(execute_js);
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
var xpath_data;
|
|
||||||
var instock_data;
|
|
||||||
try {
|
|
||||||
// Not sure the best way here, in the future this should be a new package added to npm then run in evaluatedCode
|
|
||||||
// (Once the old playwright is removed)
|
|
||||||
xpath_data = await page.evaluate((include_filters) => {%xpath_scrape_code%}, include_filters);
|
|
||||||
instock_data = await page.evaluate(() => {%instock_scrape_code%});
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protocol error (Page.captureScreenshot): Cannot take screenshot with 0 width can come from a proxy auth failure
|
|
||||||
// Wrap it here (for now)
|
|
||||||
|
|
||||||
var b64s = false;
|
|
||||||
try {
|
|
||||||
b64s = await page.screenshot({encoding: "base64", fullPage: true, quality: screenshot_quality, type: 'jpeg'});
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// May fail on very large pages with 'WARNING: tile memory limits exceeded, some content may not draw'
|
|
||||||
if (!b64s) {
|
|
||||||
// @todo after text extract, we can place some overlay text with red background to say 'croppped'
|
|
||||||
console.error('ERROR: content-fetcher page was maybe too large for a screenshot, reverting to viewport only screenshot');
|
|
||||||
try {
|
|
||||||
b64s = await page.screenshot({encoding: "base64", quality: screenshot_quality, type: 'jpeg'});
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = await page.content();
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
'content': html,
|
|
||||||
'headers': r.headers(),
|
|
||||||
'instock_data': instock_data,
|
|
||||||
'screenshot': b64s,
|
|
||||||
'status_code': r.status(),
|
|
||||||
'xpath_data': xpath_data
|
|
||||||
},
|
|
||||||
type: 'application/json',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
// Restock Detector
|
async () => {
|
||||||
// (c) Leigh Morresi dgtlmoon@gmail.com
|
|
||||||
//
|
|
||||||
// Assumes the product is in stock to begin with, unless the following appears above the fold ;
|
|
||||||
// - outOfStockTexts appears above the fold (out of stock)
|
|
||||||
// - negateOutOfStockRegex (really is in stock)
|
|
||||||
|
|
||||||
function isItemInStock() {
|
function isItemInStock() {
|
||||||
// @todo Pass these in so the same list can be used in non-JS fetchers
|
// @todo Pass these in so the same list can be used in non-JS fetchers
|
||||||
const outOfStockTexts = [
|
const outOfStockTexts = [
|
||||||
' أخبرني عندما يتوفر',
|
' أخبرني عندما يتوفر',
|
||||||
@@ -75,13 +70,19 @@ function isItemInStock() {
|
|||||||
'rupture',
|
'rupture',
|
||||||
'sold out',
|
'sold out',
|
||||||
'sold-out',
|
'sold-out',
|
||||||
|
'stok habis',
|
||||||
|
'stok kosong',
|
||||||
|
'stok varian ini habis',
|
||||||
'stokta yok',
|
'stokta yok',
|
||||||
'temporarily out of stock',
|
'temporarily out of stock',
|
||||||
'temporarily unavailable',
|
'temporarily unavailable',
|
||||||
'there were no search results for',
|
'there were no search results for',
|
||||||
'this item is currently unavailable',
|
'this item is currently unavailable',
|
||||||
'tickets unavailable',
|
'tickets unavailable',
|
||||||
|
'tidak dijual',
|
||||||
|
'tidak tersedia',
|
||||||
'tijdelijk uitverkocht',
|
'tijdelijk uitverkocht',
|
||||||
|
'tiket tidak tersedia',
|
||||||
'tükendi',
|
'tükendi',
|
||||||
'unavailable nearby',
|
'unavailable nearby',
|
||||||
'unavailable tickets',
|
'unavailable tickets',
|
||||||
@@ -123,9 +124,6 @@ function isItemInStock() {
|
|||||||
|
|
||||||
|
|
||||||
// @todo - if it's SVG or IMG, go into image diff mode
|
// @todo - if it's SVG or IMG, go into image diff mode
|
||||||
// %ELEMENTS% replaced at injection time because different interfaces use it with different settings
|
|
||||||
|
|
||||||
console.log("Scanning %ELEMENTS%");
|
|
||||||
|
|
||||||
function collectVisibleElements(parent, visibleElements) {
|
function collectVisibleElements(parent, visibleElements) {
|
||||||
if (!parent) return; // Base case: if parent is null or undefined, return
|
if (!parent) return; // Base case: if parent is null or undefined, return
|
||||||
@@ -215,9 +213,8 @@ function isItemInStock() {
|
|||||||
|
|
||||||
console.log(`Returning 'Possibly in stock' - cant' find any useful matching text`)
|
console.log(`Returning 'Possibly in stock' - cant' find any useful matching text`)
|
||||||
return 'Possibly in stock'; // possibly in stock, cant decide otherwise.
|
return 'Possibly in stock'; // possibly in stock, cant decide otherwise.
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns the element text that makes it think it's out of stock
|
// returns the element text that makes it think it's out of stock
|
||||||
return isItemInStock().trim()
|
return isItemInStock().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
// Copyright (C) 2021 Leigh Morresi (dgtlmoon@gmail.com)
|
async (options) => {
|
||||||
// All rights reserved.
|
|
||||||
|
|
||||||
// @file Scrape the page looking for elements of concern (%ELEMENTS%)
|
let visualselector_xpath_selectors = options.visualselector_xpath_selectors
|
||||||
// http://matatk.agrip.org.uk/tests/position-and-width/
|
let max_height = options.max_height
|
||||||
// https://stackoverflow.com/questions/26813480/when-is-element-getboundingclientrect-guaranteed-to-be-updated-accurate
|
|
||||||
//
|
var scroll_y = 0;
|
||||||
// Some pages like https://www.londonstockexchange.com/stock/NCCL/ncondezi-energy-limited/analysis
|
try {
|
||||||
// will automatically force a scroll somewhere, so include the position offset
|
|
||||||
// Lets hope the position doesnt change while we iterate the bbox's, but this is better than nothing
|
|
||||||
var scroll_y = 0;
|
|
||||||
try {
|
|
||||||
scroll_y = +document.documentElement.scrollTop || document.body.scrollTop
|
scroll_y = +document.documentElement.scrollTop || document.body.scrollTop
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Include the getXpath script directly, easier than fetching
|
// Include the getXpath script directly, easier than fetching
|
||||||
function getxpath(e) {
|
function getxpath(e) {
|
||||||
var n = e;
|
var n = e;
|
||||||
if (n && n.id) return '//*[@id="' + n.id + '"]';
|
if (n && n.id) return '//*[@id="' + n.id + '"]';
|
||||||
for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) {
|
for (var o = []; n && Node.ELEMENT_NODE === n.nodeType;) {
|
||||||
@@ -32,9 +26,9 @@ function getxpath(e) {
|
|||||||
o.push((n.prefix ? n.prefix + ":" : "") + n.localName + (i || r ? "[" + (i + 1) + "]" : "")), n = n.parentNode
|
o.push((n.prefix ? n.prefix + ":" : "") + n.localName + (i || r ? "[" + (i + 1) + "]" : "")), n = n.parentNode
|
||||||
}
|
}
|
||||||
return o.length ? "/" + o.reverse().join("/") : ""
|
return o.length ? "/" + o.reverse().join("/") : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const findUpTag = (el) => {
|
const findUpTag = (el) => {
|
||||||
let r = el
|
let r = el
|
||||||
chained_css = [];
|
chained_css = [];
|
||||||
depth = 0;
|
depth = 0;
|
||||||
@@ -76,25 +70,24 @@ const findUpTag = (el) => {
|
|||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// @todo - if it's SVG or IMG, go into image diff mode
|
// @todo - if it's SVG or IMG, go into image diff mode
|
||||||
// %ELEMENTS% replaced at injection time because different interfaces use it with different settings
|
|
||||||
|
|
||||||
var size_pos = [];
|
var size_pos = [];
|
||||||
// after page fetch, inject this JS
|
// after page fetch, inject this JS
|
||||||
// build a map of all elements and their positions (maybe that only include text?)
|
// build a map of all elements and their positions (maybe that only include text?)
|
||||||
var bbox;
|
var bbox;
|
||||||
console.log("Scanning %ELEMENTS%");
|
console.log(`Scanning for "${visualselector_xpath_selectors}"`);
|
||||||
|
|
||||||
function collectVisibleElements(parent, visibleElements) {
|
function collectVisibleElements(parent, visibleElements) {
|
||||||
if (!parent) return; // Base case: if parent is null or undefined, return
|
if (!parent) return; // Base case: if parent is null or undefined, return
|
||||||
|
|
||||||
|
|
||||||
// Add the parent itself to the visible elements array if it's of the specified types
|
// Add the parent itself to the visible elements array if it's of the specified types
|
||||||
const tagName = parent.tagName.toLowerCase();
|
const tagName = parent.tagName.toLowerCase();
|
||||||
if ("%ELEMENTS%".split(',').includes(tagName)) {
|
if (visualselector_xpath_selectors.split(',').includes(tagName)) {
|
||||||
visibleElements.push(parent);
|
visibleElements.push(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,16 +109,16 @@ function collectVisibleElements(parent, visibleElements) {
|
|||||||
collectVisibleElements(child, visibleElements);
|
collectVisibleElements(child, visibleElements);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create an array to hold the visible elements
|
// Create an array to hold the visible elements
|
||||||
const visibleElementsArray = [];
|
const visibleElementsArray = [];
|
||||||
|
|
||||||
// Call collectVisibleElements with the starting parent element
|
// Call collectVisibleElements with the starting parent element
|
||||||
collectVisibleElements(document.body, visibleElementsArray);
|
collectVisibleElements(document.body, visibleElementsArray);
|
||||||
|
|
||||||
|
|
||||||
visibleElementsArray.forEach(function (element) {
|
visibleElementsArray.forEach(function (element) {
|
||||||
|
|
||||||
bbox = element.getBoundingClientRect();
|
bbox = element.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -174,9 +167,13 @@ visibleElementsArray.forEach(function (element) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to identify any possible currency amounts "Sale: 4000" or "Sale now 3000 Kc", can help with the training.
|
// Try to identify any possible currency amounts "Sale: 4000" or "Sale now 3000 Kc", can help with the training.
|
||||||
const hasDigitCurrency = (/\d/.test(text.slice(0, 6)) || /\d/.test(text.slice(-6)) ) && /([€£$¥₩₹]|USD|AUD|EUR|Kč|kr|SEK|,–)/.test(text) ;
|
const hasDigitCurrency = (/\d/.test(text.slice(0, 6)) || /\d/.test(text.slice(-6))) && /([€£$¥₩₹]|USD|AUD|EUR|Kč|kr|SEK|,–)/.test(text);
|
||||||
const computedStyle = window.getComputedStyle(element);
|
const computedStyle = window.getComputedStyle(element);
|
||||||
|
|
||||||
|
if (Math.floor(bbox['top']) + scroll_y > max_height) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
size_pos.push({
|
size_pos.push({
|
||||||
xpath: xpath_result,
|
xpath: xpath_result,
|
||||||
width: Math.round(bbox['width']),
|
width: Math.round(bbox['width']),
|
||||||
@@ -195,12 +192,12 @@ visibleElementsArray.forEach(function (element) {
|
|||||||
label: label,
|
label: label,
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Inject the current one set in the include_filters, which may be a CSS rule
|
// Inject the current one set in the include_filters, which may be a CSS rule
|
||||||
// used for displaying the current one in VisualSelector, where its not one we generated.
|
// used for displaying the current one in VisualSelector, where its not one we generated.
|
||||||
if (include_filters.length) {
|
if (include_filters.length) {
|
||||||
let results;
|
let results;
|
||||||
// Foreach filter, go and find it on the page and add it to the results so we can visualise it again
|
// Foreach filter, go and find it on the page and add it to the results so we can visualise it again
|
||||||
for (const f of include_filters) {
|
for (const f of include_filters) {
|
||||||
@@ -275,11 +272,14 @@ if (include_filters.length) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort the elements so we find the smallest one first, in other words, we find the smallest one matching in that area
|
// Sort the elements so we find the smallest one first, in other words, we find the smallest one matching in that area
|
||||||
// so that we dont select the wrapping element by mistake and be unable to select what we want
|
// so that we dont select the wrapping element by mistake and be unable to select what we want
|
||||||
size_pos.sort((a, b) => (a.width * a.height > b.width * b.height) ? 1 : -1)
|
size_pos.sort((a, b) => (a.width * a.height > b.width * b.height) ? 1 : -1)
|
||||||
|
|
||||||
|
// browser_width required for proper scaling in the frontend
|
||||||
|
// Return as a string to save playwright for juggling thousands of objects
|
||||||
|
return JSON.stringify({'size_pos': size_pos, 'browser_width': window.innerWidth});
|
||||||
|
}
|
||||||
|
|
||||||
// Window.width required for proper scaling in the frontend
|
|
||||||
return {'size_pos': size_pos, 'browser_width': window.innerWidth};
|
|
||||||
|
|||||||
73
changedetectionio/content_fetchers/screenshot_handler.py
Normal file
73
changedetectionio/content_fetchers/screenshot_handler.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Pages with a vertical height longer than this will use the 'stitch together' method.
|
||||||
|
|
||||||
|
# - Many GPUs have a max texture size of 16384x16384px (or lower on older devices).
|
||||||
|
# - If a page is taller than ~8000–10000px, it risks exceeding GPU memory limits.
|
||||||
|
# - This is especially important on headless Chromium, where Playwright may fail to allocate a massive full-page buffer.
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, SCREENSHOT_DEFAULT_QUALITY
|
||||||
|
|
||||||
|
|
||||||
|
def stitch_images_worker(pipe_conn, chunks_bytes, original_page_height, capture_height):
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
# Load images from byte chunks
|
||||||
|
images = [Image.open(io.BytesIO(b)) for b in chunks_bytes]
|
||||||
|
total_height = sum(im.height for im in images)
|
||||||
|
max_width = max(im.width for im in images)
|
||||||
|
|
||||||
|
# Create stitched image
|
||||||
|
stitched = Image.new('RGB', (max_width, total_height))
|
||||||
|
y_offset = 0
|
||||||
|
for im in images:
|
||||||
|
stitched.paste(im, (0, y_offset))
|
||||||
|
y_offset += im.height
|
||||||
|
|
||||||
|
# Draw caption on top (overlaid, not extending canvas)
|
||||||
|
draw = ImageDraw.Draw(stitched)
|
||||||
|
|
||||||
|
|
||||||
|
caption_text = f"WARNING: Screenshot was {original_page_height}px but trimmed to {capture_height}px because it was too long"
|
||||||
|
padding = 10
|
||||||
|
font_size = 35
|
||||||
|
font_color = (255, 0, 0)
|
||||||
|
background_color = (255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
|
# Try to load a proper font
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("arial.ttf", font_size)
|
||||||
|
except IOError:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
bbox = draw.textbbox((0, 0), caption_text, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
# Draw white rectangle background behind text
|
||||||
|
rect_top = 0
|
||||||
|
rect_bottom = text_height + 2 * padding
|
||||||
|
draw.rectangle([(0, rect_top), (max_width, rect_bottom)], fill=background_color)
|
||||||
|
|
||||||
|
# Draw text centered horizontally, 10px padding from top of the rectangle
|
||||||
|
text_x = (max_width - text_width) // 2
|
||||||
|
text_y = padding
|
||||||
|
draw.text((text_x, text_y), caption_text, font=font, fill=font_color)
|
||||||
|
|
||||||
|
# Encode and send image
|
||||||
|
output = io.BytesIO()
|
||||||
|
stitched.save(output, format="JPEG", quality=int(os.getenv("SCREENSHOT_QUALITY", SCREENSHOT_DEFAULT_QUALITY)))
|
||||||
|
pipe_conn.send_bytes(output.getvalue())
|
||||||
|
|
||||||
|
stitched.close()
|
||||||
|
except Exception as e:
|
||||||
|
pipe_conn.send(f"error:{e}")
|
||||||
|
finally:
|
||||||
|
pipe_conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ class fetcher(Fetcher):
|
|||||||
# request_body, request_method unused for now, until some magic in the future happens.
|
# request_body, request_method unused for now, until some magic in the future happens.
|
||||||
|
|
||||||
options = ChromeOptions()
|
options = ChromeOptions()
|
||||||
|
options.add_argument("--headless")
|
||||||
if self.proxy:
|
if self.proxy:
|
||||||
options.proxy = self.proxy
|
options.proxy = self.proxy
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ class fetcher(Fetcher):
|
|||||||
self.quit()
|
self.quit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def quit(self):
|
def quit(self, watch=None):
|
||||||
if self.driver:
|
if self.driver:
|
||||||
try:
|
try:
|
||||||
self.driver.quit()
|
self.driver.quit()
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
|
||||||
import flask_login
|
import flask_login
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
import pytz
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -35,7 +33,8 @@ from loguru import logger
|
|||||||
|
|
||||||
from changedetectionio import __version__
|
from changedetectionio import __version__
|
||||||
from changedetectionio import queuedWatchMetaData
|
from changedetectionio import queuedWatchMetaData
|
||||||
from changedetectionio.api import api_v1
|
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications
|
||||||
|
from changedetectionio.api.Search import Search
|
||||||
from .time_handler import is_within_schedule
|
from .time_handler import is_within_schedule
|
||||||
|
|
||||||
datastore = None
|
datastore = None
|
||||||
@@ -124,14 +123,18 @@ def _jinja2_filter_format_number_locale(value: float) -> str:
|
|||||||
|
|
||||||
return formatted_value
|
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
|
# 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.
|
# running or something similar.
|
||||||
@app.template_filter('format_last_checked_time')
|
@app.template_filter('format_last_checked_time')
|
||||||
def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"):
|
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:
|
if watch_obj['last_checked'] == 0:
|
||||||
return 'Not yet'
|
return 'Not yet'
|
||||||
@@ -230,7 +233,8 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
|
|
||||||
if has_password_enabled and not flask_login.current_user.is_authenticated:
|
if has_password_enabled and not flask_login.current_user.is_authenticated:
|
||||||
# Permitted
|
# Permitted
|
||||||
if request.endpoint and 'static_content' in request.endpoint and request.view_args and request.view_args.get('group') == 'styles':
|
if request.endpoint and request.endpoint == 'static_content' and request.view_args:
|
||||||
|
# Handled by static_content handler
|
||||||
return None
|
return None
|
||||||
# Permitted
|
# Permitted
|
||||||
elif request.endpoint and 'login' in request.endpoint:
|
elif request.endpoint and 'login' in request.endpoint:
|
||||||
@@ -244,36 +248,45 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
# RSS access with token is allowed
|
# RSS access with token is allowed
|
||||||
elif request.endpoint and 'rss.feed' in request.endpoint:
|
elif request.endpoint and 'rss.feed' in request.endpoint:
|
||||||
return None
|
return None
|
||||||
|
# API routes - use their own auth mechanism (@auth.check_token)
|
||||||
|
elif request.path.startswith('/api/'):
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
return login_manager.unauthorized()
|
return login_manager.unauthorized()
|
||||||
|
|
||||||
|
|
||||||
watch_api.add_resource(api_v1.WatchSingleHistory,
|
watch_api.add_resource(WatchSingleHistory,
|
||||||
'/api/v1/watch/<string:uuid>/history/<string:timestamp>',
|
'/api/v1/watch/<string:uuid>/history/<string:timestamp>',
|
||||||
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
|
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',
|
'/api/v1/watch/<string:uuid>/history',
|
||||||
resource_class_kwargs={'datastore': datastore})
|
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})
|
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})
|
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})
|
resource_class_kwargs={'datastore': datastore, 'update_q': update_q})
|
||||||
|
|
||||||
watch_api.add_resource(api_v1.Import,
|
watch_api.add_resource(Import,
|
||||||
'/api/v1/import',
|
'/api/v1/import',
|
||||||
resource_class_kwargs={'datastore': datastore})
|
resource_class_kwargs={'datastore': datastore})
|
||||||
|
|
||||||
# Setup cors headers to allow all domains
|
watch_api.add_resource(Tags, '/api/v1/tags',
|
||||||
# https://flask-cors.readthedocs.io/en/latest/
|
resource_class_kwargs={'datastore': datastore})
|
||||||
# CORS(app)
|
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
watch_api.add_resource(Notifications, '/api/v1/notifications',
|
||||||
|
resource_class_kwargs={'datastore': datastore})
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def user_loader(email):
|
def user_loader(email):
|
||||||
@@ -284,12 +297,12 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
@login_manager.unauthorized_handler
|
@login_manager.unauthorized_handler
|
||||||
def unauthorized_handler():
|
def unauthorized_handler():
|
||||||
flash("You must be logged in, please log in.", 'error')
|
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')
|
@app.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
flask_login.logout_user()
|
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
|
# https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39
|
||||||
# You can divide up the stuff like this
|
# You can divide up the stuff like this
|
||||||
@@ -299,7 +312,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
if flask_login.current_user.is_authenticated:
|
if flask_login.current_user.is_authenticated:
|
||||||
flash("Already logged in")
|
flash("Already logged in")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("watchlist.index"))
|
||||||
|
|
||||||
output = render_template("login.html")
|
output = render_template("login.html")
|
||||||
return output
|
return output
|
||||||
@@ -316,13 +329,13 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
# It's more reliable and safe to ignore the 'next' redirect
|
# It's more reliable and safe to ignore the 'next' redirect
|
||||||
# When we used...
|
# When we used...
|
||||||
# next = request.args.get('next')
|
# 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
|
# We would sometimes get login loop errors on sites hosted in sub-paths
|
||||||
|
|
||||||
# note for the future:
|
# note for the future:
|
||||||
# if not is_safe_url(next):
|
# if not is_safe_url(next):
|
||||||
# return flask.abort(400)
|
# return flask.abort(400)
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('watchlist.index'))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
flash('Incorrect password', 'error')
|
flash('Incorrect password', 'error')
|
||||||
@@ -335,117 +348,19 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:
|
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['REMEMBER_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
|
||||||
app.config['SESSION_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
|
app.config['SESSION_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
|
||||||
|
|
||||||
return None
|
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'])
|
@app.route("/static/<string:group>/<string:filename>", methods=['GET'])
|
||||||
def static_content(group, filename):
|
def static_content(group, filename):
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
|
import re
|
||||||
|
group = re.sub(r'[^\w.-]+', '', group.lower())
|
||||||
|
filename = re.sub(r'[^\w.-]+', '', filename.lower())
|
||||||
|
|
||||||
if group == 'screenshot':
|
if group == 'screenshot':
|
||||||
# Could be sensitive, follow password requirements
|
# Could be sensitive, follow password requirements
|
||||||
if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
|
if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
|
||||||
|
if not datastore.data['settings']['application'].get('shared_diff_access'):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
screenshot_filename = "last-screenshot.png" if not request.args.get('error_screenshot') else "last-error-screenshot.png"
|
screenshot_filename = "last-screenshot.png" if not request.args.get('error_screenshot') else "last-error-screenshot.png"
|
||||||
@@ -480,7 +395,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
response.headers['Content-Type'] = 'application/json'
|
response.headers['Content-Type'] = 'application/json'
|
||||||
response.headers['Content-Encoding'] = 'deflate'
|
response.headers['Content-Encoding'] = 'deflate'
|
||||||
else:
|
else:
|
||||||
logger.error(f'Request elements.deflate at "{watch_directory}" but was notfound.')
|
logger.error(f'Request elements.deflate at "{watch_directory}" but was not found.')
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if response:
|
if response:
|
||||||
@@ -495,7 +410,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
|
|
||||||
# These files should be in our subdirectory
|
# These files should be in our subdirectory
|
||||||
try:
|
try:
|
||||||
return send_from_directory("static/{}".format(group), path=filename)
|
return send_from_directory(f"static/{group}", path=filename)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
@@ -524,12 +439,25 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
import changedetectionio.conditions.blueprint as conditions
|
import changedetectionio.conditions.blueprint as conditions
|
||||||
app.register_blueprint(conditions.construct_blueprint(datastore), url_prefix='/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')
|
app.register_blueprint(rss.construct_blueprint(datastore), url_prefix='/rss')
|
||||||
|
|
||||||
|
# watchlist UI buttons etc
|
||||||
import changedetectionio.blueprint.ui as ui
|
import changedetectionio.blueprint.ui as ui
|
||||||
app.register_blueprint(ui.construct_blueprint(datastore, update_q, running_update_threads, queuedWatchMetaData))
|
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='')
|
||||||
|
|
||||||
|
# Memory cleanup endpoint
|
||||||
|
@app.route('/gc-cleanup', methods=['GET'])
|
||||||
|
@login_optionally_required
|
||||||
|
def gc_cleanup():
|
||||||
|
from changedetectionio.gc_cleanup import memory_cleanup
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
result = memory_cleanup(app)
|
||||||
|
return jsonify({"status": "success", "message": "Memory cleanup completed", "result": result})
|
||||||
|
|
||||||
# @todo handle ctrl break
|
# @todo handle ctrl break
|
||||||
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
|
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
|
||||||
@@ -587,7 +515,8 @@ def notification_runner():
|
|||||||
sent_obj = None
|
sent_obj = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from changedetectionio import notification
|
from changedetectionio.notification.handler import process_notification
|
||||||
|
|
||||||
# Fallback to system config if not set
|
# Fallback to system config if not set
|
||||||
if not n_object.get('notification_body') and datastore.data['settings']['application'].get('notification_body'):
|
if not n_object.get('notification_body') and datastore.data['settings']['application'].get('notification_body'):
|
||||||
n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
|
n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
|
||||||
@@ -597,8 +526,8 @@ def notification_runner():
|
|||||||
|
|
||||||
if not n_object.get('notification_format') and datastore.data['settings']['application'].get('notification_format'):
|
if not n_object.get('notification_format') and datastore.data['settings']['application'].get('notification_format'):
|
||||||
n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format')
|
n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format')
|
||||||
|
if n_object.get('notification_urls', {}):
|
||||||
sent_obj = notification.process_notification(n_object, datastore)
|
sent_obj = process_notification(n_object, datastore)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Watch URL: {n_object['watch_url']} Error {str(e)}")
|
logger.error(f"Watch URL: {n_object['watch_url']} Error {str(e)}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import re
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from wtforms.widgets.core import TimeInput
|
from wtforms.widgets.core import TimeInput
|
||||||
|
|
||||||
|
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES
|
||||||
from changedetectionio.conditions.form import ConditionFormRow
|
from changedetectionio.conditions.form import ConditionFormRow
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
|
|
||||||
@@ -305,10 +306,10 @@ class ValidateAppRiseServers(object):
|
|||||||
|
|
||||||
def __call__(self, form, field):
|
def __call__(self, form, field):
|
||||||
import apprise
|
import apprise
|
||||||
apobj = apprise.Apprise()
|
from .notification.apprise_plugin.assets import apprise_asset
|
||||||
|
from .notification.apprise_plugin.custom_handlers import apprise_http_custom_handler # noqa: F401
|
||||||
|
|
||||||
# so that the custom endpoints are registered
|
apobj = apprise.Apprise(asset=apprise_asset)
|
||||||
from .apprise_asset import asset
|
|
||||||
|
|
||||||
for server_url in field.data:
|
for server_url in field.data:
|
||||||
url = server_url.strip()
|
url = server_url.strip()
|
||||||
@@ -585,7 +586,7 @@ class processor_text_json_diff_form(commonSettingsForm):
|
|||||||
filter_text_replaced = BooleanField('Replaced/changed lines', default=True)
|
filter_text_replaced = BooleanField('Replaced/changed lines', default=True)
|
||||||
filter_text_removed = BooleanField('Removed lines', default=True)
|
filter_text_removed = BooleanField('Removed lines', default=True)
|
||||||
|
|
||||||
trigger_text = StringListField('Trigger/wait for text', [validators.Optional(), ValidateListRegex()])
|
trigger_text = StringListField('Keyword triggers - Trigger/wait for text', [validators.Optional(), ValidateListRegex()])
|
||||||
if os.getenv("PLAYWRIGHT_DRIVER_URL"):
|
if os.getenv("PLAYWRIGHT_DRIVER_URL"):
|
||||||
browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10)
|
browser_steps = FieldList(FormField(SingleBrowserStep), min_entries=10)
|
||||||
text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()])
|
text_should_not_be_present = StringListField('Block change-detection while text matches', [validators.Optional(), ValidateListRegex()])
|
||||||
@@ -720,6 +721,8 @@ class globalSettingsRequestForm(Form):
|
|||||||
self.extra_proxies.errors.append('Both a name, and a Proxy URL is required.')
|
self.extra_proxies.errors.append('Both a name, and a Proxy URL is required.')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
class globalSettingsApplicationUIForm(Form):
|
||||||
|
open_diff_in_new_tab = BooleanField('Open diff page in a new tab', default=True, validators=[validators.Optional()])
|
||||||
|
|
||||||
# datastore.data['settings']['application']..
|
# datastore.data['settings']['application']..
|
||||||
class globalSettingsApplicationForm(commonSettingsForm):
|
class globalSettingsApplicationForm(commonSettingsForm):
|
||||||
@@ -739,6 +742,9 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
|||||||
render_kw={"style": "width: 5em;"},
|
render_kw={"style": "width: 5em;"},
|
||||||
validators=[validators.NumberRange(min=0,
|
validators=[validators.NumberRange(min=0,
|
||||||
message="Should be atleast zero (disabled)")])
|
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"})
|
removepassword_button = SubmitField('Remove password', render_kw={"class": "pure-button pure-button-primary"})
|
||||||
render_anchor_tag_content = BooleanField('Render anchor tag content', default=False)
|
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()])
|
shared_diff_access = BooleanField('Allow access to view diff page when password is enabled', default=False, validators=[validators.Optional()])
|
||||||
@@ -748,6 +754,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
|||||||
render_kw={"style": "width: 5em;"},
|
render_kw={"style": "width: 5em;"},
|
||||||
validators=[validators.NumberRange(min=0,
|
validators=[validators.NumberRange(min=0,
|
||||||
message="Should contain zero or more attempts")])
|
message="Should contain zero or more attempts")])
|
||||||
|
ui = FormField(globalSettingsApplicationUIForm)
|
||||||
|
|
||||||
|
|
||||||
class globalSettingsForm(Form):
|
class globalSettingsForm(Form):
|
||||||
|
|||||||
162
changedetectionio/gc_cleanup.py
Normal file
162
changedetectionio/gc_cleanup.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import gc
|
||||||
|
import re
|
||||||
|
import psutil
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import importlib
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
def memory_cleanup(app=None):
|
||||||
|
"""
|
||||||
|
Perform comprehensive memory cleanup operations and log memory usage
|
||||||
|
at each step with nicely formatted numbers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Optional Flask app instance for clearing Flask-specific caches
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Status message
|
||||||
|
"""
|
||||||
|
# Get current process
|
||||||
|
process = psutil.Process()
|
||||||
|
|
||||||
|
# Log initial memory usage with nicely formatted numbers
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"Memory cleanup started - Current memory usage: {current_memory:,.2f} MB")
|
||||||
|
|
||||||
|
# 1. Standard garbage collection - force full collection on all generations
|
||||||
|
gc.collect(0) # Collect youngest generation
|
||||||
|
gc.collect(1) # Collect middle generation
|
||||||
|
gc.collect(2) # Collect oldest generation
|
||||||
|
|
||||||
|
# Run full collection again to ensure maximum cleanup
|
||||||
|
gc.collect()
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After full gc.collect() - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
|
||||||
|
|
||||||
|
# 3. Call libc's malloc_trim to release memory back to the OS
|
||||||
|
libc = ctypes.CDLL("libc.so.6")
|
||||||
|
libc.malloc_trim(0)
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After malloc_trim(0) - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
|
||||||
|
# 4. Clear Python's regex cache
|
||||||
|
re.purge()
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After re.purge() - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
|
||||||
|
# 5. Reset thread-local storage
|
||||||
|
# Create a new thread local object to encourage cleanup of old ones
|
||||||
|
threading.local()
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After threading.local() - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
|
||||||
|
# 6. Clear sys.intern cache if Python version supports it
|
||||||
|
try:
|
||||||
|
sys.intern.clear()
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After sys.intern.clear() - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
logger.debug("sys.intern.clear() not supported in this Python version")
|
||||||
|
|
||||||
|
# 7. Clear XML/lxml caches if available
|
||||||
|
try:
|
||||||
|
# Check if lxml.etree is in use
|
||||||
|
lxml_etree = sys.modules.get('lxml.etree')
|
||||||
|
if lxml_etree:
|
||||||
|
# Clear module-level caches
|
||||||
|
if hasattr(lxml_etree, 'clear_error_log'):
|
||||||
|
lxml_etree.clear_error_log()
|
||||||
|
|
||||||
|
# Check for _ErrorLog and _RotatingErrorLog objects and clear them
|
||||||
|
for obj in gc.get_objects():
|
||||||
|
if hasattr(obj, '__class__') and hasattr(obj.__class__, '__name__'):
|
||||||
|
class_name = obj.__class__.__name__
|
||||||
|
if class_name in ('_ErrorLog', '_RotatingErrorLog', '_DomainErrorLog') and hasattr(obj, 'clear'):
|
||||||
|
try:
|
||||||
|
obj.clear()
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear Element objects which can hold references to documents
|
||||||
|
elif class_name in ('_Element', 'ElementBase') and hasattr(obj, 'clear'):
|
||||||
|
try:
|
||||||
|
obj.clear()
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After lxml.etree cleanup - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
|
||||||
|
# Check if lxml.html is in use
|
||||||
|
lxml_html = sys.modules.get('lxml.html')
|
||||||
|
if lxml_html:
|
||||||
|
# Clear HTML-specific element types
|
||||||
|
for obj in gc.get_objects():
|
||||||
|
if hasattr(obj, '__class__') and hasattr(obj.__class__, '__name__'):
|
||||||
|
class_name = obj.__class__.__name__
|
||||||
|
if class_name in ('HtmlElement', 'FormElement', 'InputElement',
|
||||||
|
'SelectElement', 'TextareaElement', 'CheckboxGroup',
|
||||||
|
'RadioGroup', 'MultipleSelectOptions', 'FieldsDict') and hasattr(obj, 'clear'):
|
||||||
|
try:
|
||||||
|
obj.clear()
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After lxml.html cleanup - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
logger.debug("lxml cleanup not applicable")
|
||||||
|
|
||||||
|
# 8. Clear JSON parser caches if applicable
|
||||||
|
try:
|
||||||
|
# Check if json module is being used and try to clear its cache
|
||||||
|
json_module = sys.modules.get('json')
|
||||||
|
if json_module and hasattr(json_module, '_default_encoder'):
|
||||||
|
json_module._default_encoder.markers.clear()
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After JSON parser cleanup - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
logger.debug("JSON cleanup not applicable")
|
||||||
|
|
||||||
|
# 9. Force Python's memory allocator to release unused memory
|
||||||
|
try:
|
||||||
|
if hasattr(sys, 'pypy_version_info'):
|
||||||
|
# PyPy has different memory management
|
||||||
|
gc.collect()
|
||||||
|
else:
|
||||||
|
# CPython - try to release unused memory
|
||||||
|
ctypes.pythonapi.PyGC_Collect()
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After PyGC_Collect - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
logger.debug("PyGC_Collect not supported")
|
||||||
|
|
||||||
|
# 10. Clear Flask-specific caches if applicable
|
||||||
|
if app:
|
||||||
|
try:
|
||||||
|
# Clear Flask caches if they exist
|
||||||
|
for key in list(app.config.get('_cache', {}).keys()):
|
||||||
|
app.config['_cache'].pop(key, None)
|
||||||
|
|
||||||
|
# Clear Jinja2 template cache if available
|
||||||
|
if hasattr(app, 'jinja_env') and hasattr(app.jinja_env, 'cache'):
|
||||||
|
app.jinja_env.cache.clear()
|
||||||
|
|
||||||
|
current_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.debug(f"After Flask cache clear - Memory usage: {current_memory:,.2f} MB")
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
logger.debug("No Flask cache to clear")
|
||||||
|
|
||||||
|
# Final garbage collection pass
|
||||||
|
gc.collect()
|
||||||
|
libc.malloc_trim(0)
|
||||||
|
|
||||||
|
# Log final memory usage
|
||||||
|
final_memory = process.memory_info().rss / 1024 / 1024
|
||||||
|
logger.info(f"Memory cleanup completed - Final memory usage: {final_memory:,.2f} MB")
|
||||||
|
return "cleaned"
|
||||||
@@ -366,22 +366,41 @@ def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None
|
|||||||
# wordlist - list of regex's (str) or words (str)
|
# wordlist - list of regex's (str) or words (str)
|
||||||
# Preserves all linefeeds and other whitespacing, its not the job of this to remove that
|
# Preserves all linefeeds and other whitespacing, its not the job of this to remove that
|
||||||
def strip_ignore_text(content, wordlist, mode="content"):
|
def strip_ignore_text(content, wordlist, mode="content"):
|
||||||
i = 0
|
|
||||||
output = []
|
|
||||||
ignore_text = []
|
ignore_text = []
|
||||||
ignore_regex = []
|
ignore_regex = []
|
||||||
ignored_line_numbers = []
|
ignore_regex_multiline = []
|
||||||
|
ignored_lines = []
|
||||||
|
|
||||||
for k in wordlist:
|
for k in wordlist:
|
||||||
# Is it a regex?
|
# Is it a regex?
|
||||||
res = re.search(PERL_STYLE_REGEX, k, re.IGNORECASE)
|
res = re.search(PERL_STYLE_REGEX, k, re.IGNORECASE)
|
||||||
if res:
|
if res:
|
||||||
ignore_regex.append(re.compile(perl_style_slash_enclosed_regex_to_options(k)))
|
res = re.compile(perl_style_slash_enclosed_regex_to_options(k))
|
||||||
|
if res.flags & re.DOTALL or res.flags & re.MULTILINE:
|
||||||
|
ignore_regex_multiline.append(res)
|
||||||
|
else:
|
||||||
|
ignore_regex.append(res)
|
||||||
else:
|
else:
|
||||||
ignore_text.append(k.strip())
|
ignore_text.append(k.strip())
|
||||||
|
|
||||||
for line in content.splitlines(keepends=True):
|
for r in ignore_regex_multiline:
|
||||||
i += 1
|
for match in r.finditer(content):
|
||||||
|
content_lines = content[:match.end()].splitlines(keepends=True)
|
||||||
|
match_lines = content[match.start():match.end()].splitlines(keepends=True)
|
||||||
|
|
||||||
|
end_line = len(content_lines)
|
||||||
|
start_line = end_line - len(match_lines)
|
||||||
|
|
||||||
|
if end_line - start_line <= 1:
|
||||||
|
# Match is empty or in the middle of the line
|
||||||
|
ignored_lines.append(start_line)
|
||||||
|
else:
|
||||||
|
for i in range(start_line, end_line):
|
||||||
|
ignored_lines.append(i)
|
||||||
|
|
||||||
|
line_index = 0
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
for line in lines:
|
||||||
# Always ignore blank lines in this mode. (when this function gets called)
|
# Always ignore blank lines in this mode. (when this function gets called)
|
||||||
got_match = False
|
got_match = False
|
||||||
for l in ignore_text:
|
for l in ignore_text:
|
||||||
@@ -393,17 +412,19 @@ def strip_ignore_text(content, wordlist, mode="content"):
|
|||||||
if r.search(line):
|
if r.search(line):
|
||||||
got_match = True
|
got_match = True
|
||||||
|
|
||||||
if not got_match:
|
if got_match:
|
||||||
# Not ignored, and should preserve "keepends"
|
ignored_lines.append(line_index)
|
||||||
output.append(line)
|
|
||||||
else:
|
line_index += 1
|
||||||
ignored_line_numbers.append(i)
|
|
||||||
|
ignored_lines = set([i for i in ignored_lines if i >= 0 and i < len(lines)])
|
||||||
|
|
||||||
# Used for finding out what to highlight
|
# Used for finding out what to highlight
|
||||||
if mode == "line numbers":
|
if mode == "line numbers":
|
||||||
return ignored_line_numbers
|
return [i + 1 for i in ignored_lines]
|
||||||
|
|
||||||
return ''.join(output)
|
output_lines = set(range(len(lines))) - ignored_lines
|
||||||
|
return ''.join([lines[i] for i in output_lines])
|
||||||
|
|
||||||
def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str:
|
def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False) -> str:
|
||||||
from xml.sax.saxutils import escape as xml_escape
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
@@ -414,7 +435,9 @@ def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False
|
|||||||
|
|
||||||
return re.sub(pattern, repl, html_content)
|
return re.sub(pattern, repl, html_content)
|
||||||
|
|
||||||
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False) -> str:
|
|
||||||
|
def html_to_text_sub_worker(conn, html_content: str, render_anchor_tag_content=False, is_rss=False):
|
||||||
|
|
||||||
from inscriptis import get_text
|
from inscriptis import get_text
|
||||||
from inscriptis.model.config import ParserConfig
|
from inscriptis.model.config import ParserConfig
|
||||||
|
|
||||||
@@ -449,15 +472,27 @@ def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=Fals
|
|||||||
html_content = re.sub(r'</title>', r'</h1>', html_content)
|
html_content = re.sub(r'</title>', r'</h1>', html_content)
|
||||||
|
|
||||||
text_content = get_text(html_content, config=parser_config)
|
text_content = get_text(html_content, config=parser_config)
|
||||||
|
conn.send(text_content)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
return text_content
|
# NOTE!! ANYTHING LIBXML, HTML5LIB ETC WILL CAUSE SOME SMALL MEMORY LEAK IN THE LOCAL "LIB" IMPLEMENTATION OUTSIDE PYTHON
|
||||||
|
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False):
|
||||||
|
from multiprocessing import Process, Pipe
|
||||||
|
|
||||||
|
parent_conn, child_conn = Pipe()
|
||||||
|
p = Process(target=html_to_text_sub_worker, args=(child_conn, html_content, render_anchor_tag_content, is_rss))
|
||||||
|
p.start()
|
||||||
|
text = parent_conn.recv()
|
||||||
|
p.join()
|
||||||
|
return text
|
||||||
|
|
||||||
# Does LD+JSON exist with a @type=='product' and a .price set anywhere?
|
# Does LD+JSON exist with a @type=='product' and a .price set anywhere?
|
||||||
def has_ldjson_product_info(content):
|
def has_ldjson_product_info(content):
|
||||||
try:
|
try:
|
||||||
lc = content.lower()
|
# Better than .lower() which can use a lot of ram
|
||||||
if 'application/ld+json' in lc and lc.count('"price"') == 1 and '"pricecurrency"' in lc:
|
if (re.search(r'application/ld\+json', content, re.IGNORECASE) and
|
||||||
|
re.search(r'"price"', content, re.IGNORECASE) and
|
||||||
|
re.search(r'"pricecurrency"', content, re.IGNORECASE)):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# On some pages this is really terribly expensive when they dont really need it
|
# On some pages this is really terribly expensive when they dont really need it
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
from os import getenv
|
from os import getenv
|
||||||
|
|
||||||
|
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES
|
||||||
|
|
||||||
from changedetectionio.notification import (
|
from changedetectionio.notification import (
|
||||||
default_notification_body,
|
default_notification_body,
|
||||||
default_notification_format,
|
default_notification_format,
|
||||||
@@ -9,6 +12,8 @@ from changedetectionio.notification import (
|
|||||||
_FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT = 6
|
_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'
|
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):
|
class model(dict):
|
||||||
base_config = {
|
base_config = {
|
||||||
'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!",
|
'note': "Hello! If you change this file manually, please be sure to restart your changedetection.io instance!",
|
||||||
@@ -48,12 +53,16 @@ class model(dict):
|
|||||||
'password': False,
|
'password': False,
|
||||||
'render_anchor_tag_content': False,
|
'render_anchor_tag_content': False,
|
||||||
'rss_access_token': None,
|
'rss_access_token': None,
|
||||||
|
'rss_content_format': RSS_FORMAT_TYPES[0][0],
|
||||||
'rss_hide_muted_watches': True,
|
'rss_hide_muted_watches': True,
|
||||||
'schema_version' : 0,
|
'schema_version' : 0,
|
||||||
'shared_diff_access': False,
|
'shared_diff_access': False,
|
||||||
'webdriver_delay': None , # Extra delay in seconds before extracting text
|
'webdriver_delay': None , # Extra delay in seconds before extracting text
|
||||||
'tags': {}, #@todo use Tag.model initialisers
|
'tags': {}, #@todo use Tag.model initialisers
|
||||||
'timezone': None, # Default IANA timezone name
|
'timezone': None, # Default IANA timezone name
|
||||||
|
'ui': {
|
||||||
|
'open_diff_in_new_tab': True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -553,7 +553,10 @@ class model(watch_base):
|
|||||||
self.ensure_data_dir_exists()
|
self.ensure_data_dir_exists()
|
||||||
|
|
||||||
with open(target_path, 'wb') as f:
|
with open(target_path, 'wb') as f:
|
||||||
|
if not isinstance(data, str):
|
||||||
f.write(zlib.compress(json.dumps(data).encode()))
|
f.write(zlib.compress(json.dumps(data).encode()))
|
||||||
|
else:
|
||||||
|
f.write(zlib.compress(data.encode()))
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
# Save as PNG, PNG is larger but better for doing visual diff in the future
|
# Save as PNG, PNG is larger but better for doing visual diff in the future
|
||||||
@@ -575,7 +578,7 @@ class model(watch_base):
|
|||||||
import brotli
|
import brotli
|
||||||
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
|
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
|
||||||
|
|
||||||
if not os.path.isfile(filepath):
|
if not os.path.isfile(filepath) or os.path.getsize(filepath) == 0:
|
||||||
# If a previous attempt doesnt yet exist, just snarf the previous snapshot instead
|
# If a previous attempt doesnt yet exist, just snarf the previous snapshot instead
|
||||||
dates = list(self.history.keys())
|
dates = list(self.history.keys())
|
||||||
if len(dates):
|
if len(dates):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from changedetectionio import strtobool
|
from changedetectionio import strtobool
|
||||||
from changedetectionio.notification import default_notification_format_for_watch
|
default_notification_format_for_watch = 'System default'
|
||||||
|
|
||||||
class watch_base(dict):
|
class watch_base(dict):
|
||||||
|
|
||||||
|
|||||||
35
changedetectionio/notification/__init__.py
Normal file
35
changedetectionio/notification/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from changedetectionio.model import default_notification_format_for_watch
|
||||||
|
|
||||||
|
ult_notification_format_for_watch = 'System default'
|
||||||
|
default_notification_format = 'HTML Color'
|
||||||
|
default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n'
|
||||||
|
default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}'
|
||||||
|
|
||||||
|
# The values (markdown etc) are from apprise NotifyFormat,
|
||||||
|
# But to avoid importing the whole heavy module just use the same strings here.
|
||||||
|
valid_notification_formats = {
|
||||||
|
'Text': 'text',
|
||||||
|
'Markdown': 'markdown',
|
||||||
|
'HTML': 'html',
|
||||||
|
'HTML Color': 'htmlcolor',
|
||||||
|
# Used only for editing a watch (not for global)
|
||||||
|
default_notification_format_for_watch: default_notification_format_for_watch
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
valid_tokens = {
|
||||||
|
'base_url': '',
|
||||||
|
'current_snapshot': '',
|
||||||
|
'diff': '',
|
||||||
|
'diff_added': '',
|
||||||
|
'diff_full': '',
|
||||||
|
'diff_patch': '',
|
||||||
|
'diff_removed': '',
|
||||||
|
'diff_url': '',
|
||||||
|
'preview_url': '',
|
||||||
|
'triggered_text': '',
|
||||||
|
'watch_tag': '',
|
||||||
|
'watch_title': '',
|
||||||
|
'watch_url': '',
|
||||||
|
'watch_uuid': '',
|
||||||
|
}
|
||||||
16
changedetectionio/notification/apprise_plugin/assets.py
Normal file
16
changedetectionio/notification/apprise_plugin/assets.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from apprise import AppriseAsset
|
||||||
|
|
||||||
|
# Refer to:
|
||||||
|
# https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object
|
||||||
|
|
||||||
|
APPRISE_APP_ID = "changedetection.io"
|
||||||
|
APPRISE_APP_DESC = "ChangeDetection.io best and simplest website monitoring and change detection"
|
||||||
|
APPRISE_APP_URL = "https://changedetection.io"
|
||||||
|
APPRISE_AVATAR_URL = "https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png"
|
||||||
|
|
||||||
|
apprise_asset = AppriseAsset(
|
||||||
|
app_id=APPRISE_APP_ID,
|
||||||
|
app_desc=APPRISE_APP_DESC,
|
||||||
|
app_url=APPRISE_APP_URL,
|
||||||
|
image_url_logo=APPRISE_AVATAR_URL,
|
||||||
|
)
|
||||||
112
changedetectionio/notification/apprise_plugin/custom_handlers.py
Normal file
112
changedetectionio/notification/apprise_plugin/custom_handlers.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from apprise.decorators import notify
|
||||||
|
from apprise.utils.parse import parse_url as apprise_parse_url
|
||||||
|
from loguru import logger
|
||||||
|
from requests.structures import CaseInsensitiveDict
|
||||||
|
|
||||||
|
SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"}
|
||||||
|
|
||||||
|
|
||||||
|
def notify_supported_methods(func):
|
||||||
|
for method in SUPPORTED_HTTP_METHODS:
|
||||||
|
func = notify(on=method)(func)
|
||||||
|
# Add support for https, for each supported http method
|
||||||
|
func = notify(on=f"{method}s")(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
def _get_auth(parsed_url: dict) -> str | tuple[str, str]:
|
||||||
|
user: str | None = parsed_url.get("user")
|
||||||
|
password: str | None = parsed_url.get("password")
|
||||||
|
|
||||||
|
if user is not None and password is not None:
|
||||||
|
return (unquote_plus(user), unquote_plus(password))
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
return unquote_plus(user)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_headers(parsed_url: dict, body: str) -> CaseInsensitiveDict:
|
||||||
|
headers = CaseInsensitiveDict(
|
||||||
|
{unquote_plus(k).title(): unquote_plus(v) for k, v in parsed_url["qsd+"].items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
# If Content-Type is not specified, guess if the body is a valid JSON
|
||||||
|
if headers.get("Content-Type") is None:
|
||||||
|
try:
|
||||||
|
json.loads(body)
|
||||||
|
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _get_params(parsed_url: dict) -> CaseInsensitiveDict:
|
||||||
|
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
|
||||||
|
# In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
|
||||||
|
# but here we are making straight requests, so we need todo convert this against apprise's logic
|
||||||
|
params = CaseInsensitiveDict(
|
||||||
|
{
|
||||||
|
unquote_plus(k): unquote_plus(v)
|
||||||
|
for k, v in parsed_url["qsd"].items()
|
||||||
|
if k.strip("-") not in parsed_url["qsd-"]
|
||||||
|
and k.strip("+") not in parsed_url["qsd+"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
@notify_supported_methods
|
||||||
|
def apprise_http_custom_handler(
|
||||||
|
body: str,
|
||||||
|
title: str,
|
||||||
|
notify_type: str,
|
||||||
|
meta: dict,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> bool:
|
||||||
|
url: str = meta.get("url")
|
||||||
|
schema: str = meta.get("schema")
|
||||||
|
method: str = re.sub(r"s$", "", schema).upper()
|
||||||
|
|
||||||
|
# Convert /foobar?+some-header=hello to proper header dictionary
|
||||||
|
parsed_url: dict[str, str | dict | None] | None = apprise_parse_url(url)
|
||||||
|
if parsed_url is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
auth = _get_auth(parsed_url=parsed_url)
|
||||||
|
headers = _get_headers(parsed_url=parsed_url, body=body)
|
||||||
|
params = _get_params(parsed_url=parsed_url)
|
||||||
|
|
||||||
|
url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
auth=auth,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
data=body.encode("utf-8") if isinstance(body, str) else body,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
logger.info(f"Successfully sent custom notification to {url}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Remote host error while sending custom notification to {url}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error occurred while sending custom notification to {url}: {e}")
|
||||||
|
return False
|
||||||
@@ -1,48 +1,17 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from apprise import NotifyFormat
|
|
||||||
import apprise
|
import apprise
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL
|
||||||
valid_tokens = {
|
|
||||||
'base_url': '',
|
|
||||||
'current_snapshot': '',
|
|
||||||
'diff': '',
|
|
||||||
'diff_added': '',
|
|
||||||
'diff_full': '',
|
|
||||||
'diff_patch': '',
|
|
||||||
'diff_removed': '',
|
|
||||||
'diff_url': '',
|
|
||||||
'preview_url': '',
|
|
||||||
'triggered_text': '',
|
|
||||||
'watch_tag': '',
|
|
||||||
'watch_title': '',
|
|
||||||
'watch_url': '',
|
|
||||||
'watch_uuid': '',
|
|
||||||
}
|
|
||||||
|
|
||||||
default_notification_format_for_watch = 'System default'
|
|
||||||
default_notification_format = 'HTML Color'
|
|
||||||
default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n'
|
|
||||||
default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}'
|
|
||||||
|
|
||||||
valid_notification_formats = {
|
|
||||||
'Text': NotifyFormat.TEXT,
|
|
||||||
'Markdown': NotifyFormat.MARKDOWN,
|
|
||||||
'HTML': NotifyFormat.HTML,
|
|
||||||
'HTML Color': 'htmlcolor',
|
|
||||||
# Used only for editing a watch (not for global)
|
|
||||||
default_notification_format_for_watch: default_notification_format_for_watch
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def process_notification(n_object, datastore):
|
def process_notification(n_object, datastore):
|
||||||
# so that the custom endpoints are registered
|
from changedetectionio.safe_jinja import render as jinja_render
|
||||||
from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
|
from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats
|
||||||
|
# be sure its registered
|
||||||
|
from .apprise_plugin.custom_handlers import apprise_http_custom_handler
|
||||||
|
|
||||||
from .safe_jinja import render as jinja_render
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if n_object.get('notification_timestamp'):
|
if n_object.get('notification_timestamp'):
|
||||||
logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s")
|
logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s")
|
||||||
@@ -59,19 +28,18 @@ def process_notification(n_object, datastore):
|
|||||||
# Initially text or whatever
|
# Initially text or whatever
|
||||||
n_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format])
|
n_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format])
|
||||||
|
|
||||||
logger.trace(f"Complete notification body including Jinja and placeholders calculated in {time.time() - now:.3f}s")
|
logger.trace(f"Complete notification body including Jinja and placeholders calculated in {time.time() - now:.2f}s")
|
||||||
|
|
||||||
# https://github.com/caronc/apprise/wiki/Development_LogCapture
|
# https://github.com/caronc/apprise/wiki/Development_LogCapture
|
||||||
# Anything higher than or equal to WARNING (which covers things like Connection errors)
|
# Anything higher than or equal to WARNING (which covers things like Connection errors)
|
||||||
# raise it as an exception
|
# raise it as an exception
|
||||||
|
|
||||||
sent_objs = []
|
sent_objs = []
|
||||||
from .apprise_asset import asset
|
|
||||||
|
|
||||||
if 'as_async' in n_object:
|
if 'as_async' in n_object:
|
||||||
asset.async_mode = n_object.get('as_async')
|
apprise_asset.async_mode = n_object.get('as_async')
|
||||||
|
|
||||||
apobj = apprise.Apprise(debug=True, asset=asset)
|
apobj = apprise.Apprise(debug=True, asset=apprise_asset)
|
||||||
|
|
||||||
if not n_object.get('notification_urls'):
|
if not n_object.get('notification_urls'):
|
||||||
return None
|
return None
|
||||||
@@ -112,7 +80,7 @@ def process_notification(n_object, datastore):
|
|||||||
and not url.startswith('get') \
|
and not url.startswith('get') \
|
||||||
and not url.startswith('delete') \
|
and not url.startswith('delete') \
|
||||||
and not url.startswith('put'):
|
and not url.startswith('put'):
|
||||||
url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png'
|
url += k + f"avatar_url={APPRISE_AVATAR_URL}"
|
||||||
|
|
||||||
if url.startswith('tgram://'):
|
if url.startswith('tgram://'):
|
||||||
# Telegram only supports a limit subset of HTML, remove the '<br>' we place in.
|
# Telegram only supports a limit subset of HTML, remove the '<br>' we place in.
|
||||||
@@ -177,6 +145,7 @@ def process_notification(n_object, datastore):
|
|||||||
# ( Where we prepare the tokens in the notification to be replaced with actual values )
|
# ( Where we prepare the tokens in the notification to be replaced with actual values )
|
||||||
def create_notification_parameters(n_object, datastore):
|
def create_notification_parameters(n_object, datastore):
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from . import valid_tokens
|
||||||
|
|
||||||
# in the case we send a test notification from the main settings, there is no UUID.
|
# in the case we send a test notification from the main settings, there is no UUID.
|
||||||
uuid = n_object['uuid'] if 'uuid' in n_object else ''
|
uuid = n_object['uuid'] if 'uuid' in n_object else ''
|
||||||
@@ -159,7 +159,7 @@ class difference_detection_processor():
|
|||||||
)
|
)
|
||||||
|
|
||||||
#@todo .quit here could go on close object, so we can run JS if change-detected
|
#@todo .quit here could go on close object, so we can run JS if change-detected
|
||||||
self.fetcher.quit()
|
self.fetcher.quit(watch=self.watch)
|
||||||
|
|
||||||
# After init, call run_changedetection() which will do the actual change-detection
|
# After init, call run_changedetection() which will do the actual change-detection
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
|
|
||||||
# 615 Extract text by regex
|
# 615 Extract text by regex
|
||||||
extract_text = watch.get('extract_text', [])
|
extract_text = watch.get('extract_text', [])
|
||||||
|
extract_text += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='extract_text')
|
||||||
if len(extract_text) > 0:
|
if len(extract_text) > 0:
|
||||||
regex_matched_output = []
|
regex_matched_output = []
|
||||||
for s_re in extract_text:
|
for s_re in extract_text:
|
||||||
@@ -296,6 +297,8 @@ class perform_site_check(difference_detection_processor):
|
|||||||
### CALCULATE MD5
|
### CALCULATE MD5
|
||||||
# If there's text to ignore
|
# If there's text to ignore
|
||||||
text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', [])
|
text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', [])
|
||||||
|
text_to_ignore += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='ignore_text')
|
||||||
|
|
||||||
text_for_checksuming = stripped_text_from_html
|
text_for_checksuming = stripped_text_from_html
|
||||||
if text_to_ignore:
|
if text_to_ignore:
|
||||||
text_for_checksuming = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore)
|
text_for_checksuming = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore)
|
||||||
@@ -308,8 +311,8 @@ class perform_site_check(difference_detection_processor):
|
|||||||
|
|
||||||
############ Blocking rules, after checksum #################
|
############ Blocking rules, after checksum #################
|
||||||
blocked = False
|
blocked = False
|
||||||
|
|
||||||
trigger_text = watch.get('trigger_text', [])
|
trigger_text = watch.get('trigger_text', [])
|
||||||
|
trigger_text += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='trigger_text')
|
||||||
if len(trigger_text):
|
if len(trigger_text):
|
||||||
# Assume blocked
|
# Assume blocked
|
||||||
blocked = True
|
blocked = True
|
||||||
@@ -324,6 +327,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
blocked = False
|
blocked = False
|
||||||
|
|
||||||
text_should_not_be_present = watch.get('text_should_not_be_present', [])
|
text_should_not_be_present = watch.get('text_should_not_be_present', [])
|
||||||
|
text_should_not_be_present += self.datastore.get_tag_overrides_for_watch(uuid=watch.get('uuid'), attr='text_should_not_be_present')
|
||||||
if len(text_should_not_be_present):
|
if len(text_should_not_be_present):
|
||||||
# If anything matched, then we should block a change from happening
|
# If anything matched, then we should block a change from happening
|
||||||
result = html_tools.strip_ignore_text(content=str(stripped_text_from_html),
|
result = html_tools.strip_ignore_text(content=str(stripped_text_from_html),
|
||||||
@@ -334,12 +338,14 @@ class perform_site_check(difference_detection_processor):
|
|||||||
|
|
||||||
# And check if 'conditions' will let this pass through
|
# And check if 'conditions' will let this pass through
|
||||||
if watch.get('conditions') and watch.get('conditions_match_logic'):
|
if watch.get('conditions') and watch.get('conditions_match_logic'):
|
||||||
if not execute_ruleset_against_all_plugins(current_watch_uuid=watch.get('uuid'),
|
conditions_result = execute_ruleset_against_all_plugins(current_watch_uuid=watch.get('uuid'),
|
||||||
application_datastruct=self.datastore.data,
|
application_datastruct=self.datastore.data,
|
||||||
ephemeral_data={
|
ephemeral_data={
|
||||||
'text': stripped_text_from_html
|
'text': stripped_text_from_html
|
||||||
}
|
}
|
||||||
):
|
)
|
||||||
|
|
||||||
|
if not conditions_result.get('result'):
|
||||||
# Conditions say "Condition not met" so we block it.
|
# Conditions say "Condition not met" so we block it.
|
||||||
blocked = True
|
blocked = True
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
|||||||
find tests/test_*py -type f|while read test_name
|
find tests/test_*py -type f|while read test_name
|
||||||
do
|
do
|
||||||
echo "TEST RUNNING $test_name"
|
echo "TEST RUNNING $test_name"
|
||||||
pytest $test_name
|
# REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser
|
||||||
|
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest $test_name
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "RUNNING WITH BASE_URL SET"
|
echo "RUNNING WITH BASE_URL SET"
|
||||||
@@ -22,7 +23,7 @@ echo "RUNNING WITH BASE_URL SET"
|
|||||||
# Now re-run some tests with BASE_URL enabled
|
# Now re-run some tests with BASE_URL enabled
|
||||||
# Re #65 - Ability to include a link back to the installation, in the notification.
|
# Re #65 - Ability to include a link back to the installation, in the notification.
|
||||||
export BASE_URL="https://really-unique-domain.io"
|
export BASE_URL="https://really-unique-domain.io"
|
||||||
pytest tests/test_notification.py
|
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
|
||||||
|
|
||||||
|
|
||||||
# Re-run with HIDE_REFERER set - could affect login
|
# Re-run with HIDE_REFERER set - could affect login
|
||||||
@@ -32,7 +33,7 @@ pytest tests/test_access_control.py
|
|||||||
# Re-run a few tests that will trigger brotli based storage
|
# Re-run a few tests that will trigger brotli based storage
|
||||||
export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5
|
export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5
|
||||||
pytest tests/test_access_control.py
|
pytest tests/test_access_control.py
|
||||||
pytest tests/test_notification.py
|
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
|
||||||
pytest tests/test_backend.py
|
pytest tests/test_backend.py
|
||||||
pytest tests/test_rss.py
|
pytest tests/test_rss.py
|
||||||
pytest tests/test_unique_lines.py
|
pytest tests/test_unique_lines.py
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 569 B After Width: | Height: | Size: 569 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -8,7 +8,7 @@ $(document).ready(function () {
|
|||||||
$(".addRuleRow").on("click", function(e) {
|
$(".addRuleRow").on("click", function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
let currentRow = $(this).closest("tr");
|
let currentRow = $(this).closest(".fieldlist-row");
|
||||||
|
|
||||||
// Clone without events
|
// Clone without events
|
||||||
let newRow = currentRow.clone(false);
|
let newRow = currentRow.clone(false);
|
||||||
@@ -29,8 +29,8 @@ $(document).ready(function () {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Only remove if there's more than one row
|
// Only remove if there's more than one row
|
||||||
if ($("#rulesTable tbody tr").length > 1) {
|
if ($("#rulesTable .fieldlist-row").length > 1) {
|
||||||
$(this).closest("tr").remove();
|
$(this).closest(".fieldlist-row").remove();
|
||||||
reindexRules();
|
reindexRules();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -39,7 +39,7 @@ $(document).ready(function () {
|
|||||||
$(".verifyRuleRow").on("click", function(e) {
|
$(".verifyRuleRow").on("click", function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
let row = $(this).closest("tr");
|
let row = $(this).closest(".fieldlist-row");
|
||||||
let field = row.find("select[name$='field']").val();
|
let field = row.find("select[name$='field']").val();
|
||||||
let operator = row.find("select[name$='operator']").val();
|
let operator = row.find("select[name$='operator']").val();
|
||||||
let value = row.find("input[name$='value']").val();
|
let value = row.find("input[name$='value']").val();
|
||||||
@@ -52,7 +52,7 @@ $(document).ready(function () {
|
|||||||
|
|
||||||
|
|
||||||
// Create a rule object
|
// Create a rule object
|
||||||
const rule = {
|
let rule = {
|
||||||
field: field,
|
field: field,
|
||||||
operator: operator,
|
operator: operator,
|
||||||
value: value
|
value: value
|
||||||
@@ -96,6 +96,10 @@ $(document).ready(function () {
|
|||||||
contentType: false, // Let the browser set the correct content type
|
contentType: false, // Let the browser set the correct content type
|
||||||
success: function (response) {
|
success: function (response) {
|
||||||
if (response.status === "success") {
|
if (response.status === "success") {
|
||||||
|
if(rule['field'] !== "page_filtered_text") {
|
||||||
|
// A little debug helper for the user
|
||||||
|
$('#verify-state-text').text(`${rule['field']} was value "${response.data[rule['field']]}"`)
|
||||||
|
}
|
||||||
if (response.result) {
|
if (response.result) {
|
||||||
alert("✅ Condition PASSES verification against current snapshot!");
|
alert("✅ Condition PASSES verification against current snapshot!");
|
||||||
} else {
|
} else {
|
||||||
@@ -124,7 +128,7 @@ $(document).ready(function () {
|
|||||||
$(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click");
|
$(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click");
|
||||||
|
|
||||||
// Reindex all form elements
|
// Reindex all form elements
|
||||||
$("#rulesTable tbody tr").each(function(index) {
|
$("#rulesTable .fieldlist-row").each(function(index) {
|
||||||
$(this).find("select, input").each(function() {
|
$(this).find("select, input").each(function() {
|
||||||
let oldName = $(this).attr("name");
|
let oldName = $(this).attr("name");
|
||||||
let oldId = $(this).attr("id");
|
let oldId = $(this).attr("id");
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ $(function () {
|
|||||||
$('input[type=checkbox]').not(this).prop('checked', this.checked);
|
$('input[type=checkbox]').not(this).prop('checked', this.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const time_check_step_size_seconds=1;
|
||||||
|
|
||||||
// checkboxes - show/hide buttons
|
// checkboxes - show/hide buttons
|
||||||
$("input[type=checkbox]").click(function (e) {
|
$("input[type=checkbox]").click(function (e) {
|
||||||
if ($('input[type=checkbox]:checked').length) {
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/* Styles for the flexbox-based table replacement for conditions */
|
||||||
|
.fieldlist_formfields {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-background, #fff);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border-table-cell, #cbcbcb);
|
||||||
|
|
||||||
|
/* Header row */
|
||||||
|
.fieldlist-header {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--color-background-table-thead, #e0e0e0);
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid var(--color-border-table-cell, #cbcbcb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldlist-header-cell {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
flex: 0 0 120px; /* Fixed width for actions column */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body rows */
|
||||||
|
.fieldlist-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldlist-row {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border-table-cell, #cbcbcb);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2n-1) {
|
||||||
|
background-color: var(--color-table-stripe, #f2f2f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error-row {
|
||||||
|
background-color: var(--color-error-input, #ffdddd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldlist-cell {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
/* Make inputs take up full width of their cell */
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fieldlist-actions {
|
||||||
|
flex: 0 0 120px; /* Fixed width for actions column */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error styling */
|
||||||
|
ul.errors {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 0.5em;
|
||||||
|
background-color: var(--color-error-background-snapshot-age, #ffdddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
list-style-position: inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive styles */
|
||||||
|
@media only screen and (max-width: 760px) {
|
||||||
|
.fieldlist-header, .fieldlist-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldlist-header-cell {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldlist-row {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
border-bottom: 2px solid var(--color-border-table-cell, #cbcbcb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldlist-cell {
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
|
||||||
|
&.fieldlist-actions {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add some spacing between fields on mobile */
|
||||||
|
.fieldlist-cell:not(:last-child) {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label each cell on mobile view */
|
||||||
|
.fieldlist-cell::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styling */
|
||||||
|
.fieldlist_formfields {
|
||||||
|
.addRuleRow, .removeRuleRow, .verifyRuleRow {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #aaa;
|
||||||
|
color: var(--color-foreground-text, #fff);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
@import "parts/_love";
|
@import "parts/_love";
|
||||||
@import "parts/preview_text_filter";
|
@import "parts/preview_text_filter";
|
||||||
@import "parts/_edit";
|
@import "parts/_edit";
|
||||||
|
@import "parts/_conditions_table";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|||||||
@@ -530,6 +530,99 @@ ul#conditions_match_logic {
|
|||||||
ul#conditions_match_logic li {
|
ul#conditions_match_logic li {
|
||||||
padding-right: 1em; }
|
padding-right: 1em; }
|
||||||
|
|
||||||
|
/* Styles for the flexbox-based table replacement for conditions */
|
||||||
|
.fieldlist_formfields {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-background, #fff);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border-table-cell, #cbcbcb);
|
||||||
|
/* Header row */
|
||||||
|
/* Body rows */
|
||||||
|
/* Error styling */
|
||||||
|
/* Responsive styles */ }
|
||||||
|
.fieldlist_formfields .fieldlist-header {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--color-background-table-thead, #e0e0e0);
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid var(--color-border-table-cell, #cbcbcb); }
|
||||||
|
.fieldlist_formfields .fieldlist-header-cell {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
text-align: left; }
|
||||||
|
.fieldlist_formfields .fieldlist-header-cell:last-child {
|
||||||
|
flex: 0 0 120px;
|
||||||
|
/* Fixed width for actions column */ }
|
||||||
|
.fieldlist_formfields .fieldlist-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; }
|
||||||
|
.fieldlist_formfields .fieldlist-row {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border-table-cell, #cbcbcb); }
|
||||||
|
.fieldlist_formfields .fieldlist-row:last-child {
|
||||||
|
border-bottom: none; }
|
||||||
|
.fieldlist_formfields .fieldlist-row:nth-child(2n-1) {
|
||||||
|
background-color: var(--color-table-stripe, #f2f2f2); }
|
||||||
|
.fieldlist_formfields .fieldlist-row.error-row {
|
||||||
|
background-color: var(--color-error-input, #ffdddd); }
|
||||||
|
.fieldlist_formfields .fieldlist-cell {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
/* Make inputs take up full width of their cell */ }
|
||||||
|
.fieldlist_formfields .fieldlist-cell input, .fieldlist_formfields .fieldlist-cell select {
|
||||||
|
width: 100%; }
|
||||||
|
.fieldlist_formfields .fieldlist-cell.fieldlist-actions {
|
||||||
|
flex: 0 0 120px;
|
||||||
|
/* Fixed width for actions column */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px; }
|
||||||
|
.fieldlist_formfields ul.errors {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 0.5em;
|
||||||
|
background-color: var(--color-error-background-snapshot-age, #ffdddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
list-style-position: inside; }
|
||||||
|
@media only screen and (max-width: 760px) {
|
||||||
|
.fieldlist_formfields {
|
||||||
|
/* Add some spacing between fields on mobile */
|
||||||
|
/* Label each cell on mobile view */ }
|
||||||
|
.fieldlist_formfields .fieldlist-header, .fieldlist_formfields .fieldlist-row {
|
||||||
|
flex-direction: column; }
|
||||||
|
.fieldlist_formfields .fieldlist-header-cell {
|
||||||
|
display: none; }
|
||||||
|
.fieldlist_formfields .fieldlist-row {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
border-bottom: 2px solid var(--color-border-table-cell, #cbcbcb); }
|
||||||
|
.fieldlist_formfields .fieldlist-cell {
|
||||||
|
padding: 0.25em 0.5em; }
|
||||||
|
.fieldlist_formfields .fieldlist-cell.fieldlist-actions {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: 0.5em; }
|
||||||
|
.fieldlist_formfields .fieldlist-cell:not(:last-child) {
|
||||||
|
margin-bottom: 0.5em; }
|
||||||
|
.fieldlist_formfields .fieldlist-cell::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.25em; } }
|
||||||
|
|
||||||
|
/* Button styling */
|
||||||
|
.fieldlist_formfields .addRuleRow, .fieldlist_formfields .removeRuleRow, .fieldlist_formfields .verifyRuleRow {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #aaa;
|
||||||
|
color: var(--color-foreground-text, #fff); }
|
||||||
|
.fieldlist_formfields .addRuleRow:hover, .fieldlist_formfields .removeRuleRow:hover, .fieldlist_formfields .verifyRuleRow:hover {
|
||||||
|
background-color: #999; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-background-page);
|
background: var(--color-background-page);
|
||||||
|
|||||||
@@ -251,8 +251,14 @@ class ChangeDetectionStore:
|
|||||||
# Clone a watch by UUID
|
# Clone a watch by UUID
|
||||||
def clone(self, uuid):
|
def clone(self, uuid):
|
||||||
url = self.data['watching'][uuid].get('url')
|
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)
|
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
|
return new_uuid
|
||||||
|
|
||||||
def url_exists(self, url):
|
def url_exists(self, url):
|
||||||
@@ -363,7 +369,6 @@ class ChangeDetectionStore:
|
|||||||
new_watch.ensure_data_dir_exists()
|
new_watch.ensure_data_dir_exists()
|
||||||
self.__data['watching'][new_uuid] = new_watch
|
self.__data['watching'][new_uuid] = new_watch
|
||||||
|
|
||||||
|
|
||||||
if write_to_disk_now:
|
if write_to_disk_now:
|
||||||
self.sync_to_json()
|
self.sync_to_json()
|
||||||
|
|
||||||
@@ -571,16 +576,16 @@ class ChangeDetectionStore:
|
|||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def add_tag(self, name):
|
def add_tag(self, title):
|
||||||
# If name exists, return that
|
# If name exists, return that
|
||||||
n = name.strip().lower()
|
n = title.strip().lower()
|
||||||
logger.debug(f">>> Adding new tag - '{n}'")
|
logger.debug(f">>> Adding new tag - '{n}'")
|
||||||
if not n:
|
if not n:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
|
for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
|
||||||
if n == tag.get('title', '').lower().strip():
|
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
|
return uuid
|
||||||
|
|
||||||
# Eventually almost everything todo with a watch will apply as a Tag
|
# Eventually almost everything todo with a watch will apply as a Tag
|
||||||
@@ -588,7 +593,7 @@ class ChangeDetectionStore:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
from .model import Tag
|
from .model import Tag
|
||||||
new_tag = Tag.model(datastore_path=self.datastore_path, default={
|
new_tag = Tag.model(datastore_path=self.datastore_path, default={
|
||||||
'title': name.strip(),
|
'title': title.strip(),
|
||||||
'date_created': int(time.time())
|
'date_created': int(time.time())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -632,6 +637,41 @@ class ChangeDetectionStore:
|
|||||||
return True
|
return True
|
||||||
return False
|
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):
|
def get_unique_notification_tokens_available(self):
|
||||||
# Ask each type of watch if they have any extra notification token to add to the validation
|
# Ask each type of watch if they have any extra notification token to add to the validation
|
||||||
extra_notification_tokens = {}
|
extra_notification_tokens = {}
|
||||||
@@ -847,7 +887,7 @@ class ChangeDetectionStore:
|
|||||||
if tag:
|
if tag:
|
||||||
tag_uuids = []
|
tag_uuids = []
|
||||||
for t in tag.split(','):
|
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
|
self.data['watching'][uuid]['tags'] = tag_uuids
|
||||||
|
|
||||||
@@ -924,3 +964,25 @@ class ChangeDetectionStore:
|
|||||||
f_d.write(zlib.compress(f_j.read()))
|
f_d.write(zlib.compress(f_j.read()))
|
||||||
os.unlink(json_path)
|
os.unlink(json_path)
|
||||||
|
|
||||||
|
def add_notification_url(self, notification_url):
|
||||||
|
|
||||||
|
logger.debug(f">>> Adding new notification_url - '{notification_url}'")
|
||||||
|
|
||||||
|
notification_urls = self.data['settings']['application'].get('notification_urls', [])
|
||||||
|
|
||||||
|
if notification_url in notification_urls:
|
||||||
|
return notification_url
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
notification_urls = self.__data['settings']['application'].get('notification_urls', [])
|
||||||
|
|
||||||
|
if notification_url in notification_urls:
|
||||||
|
return notification_url
|
||||||
|
|
||||||
|
# Append and update the datastore
|
||||||
|
notification_urls.append(notification_url)
|
||||||
|
self.__data['settings']['application']['notification_urls'] = notification_urls
|
||||||
|
self.needs_write = True
|
||||||
|
|
||||||
|
return notification_url
|
||||||
|
|
||||||
|
|||||||
@@ -61,21 +61,20 @@
|
|||||||
{{ field(**kwargs)|safe }}
|
{{ field(**kwargs)|safe }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro render_fieldlist_of_formfields_as_table(fieldlist, table_id="rulesTable") %}
|
{% macro render_conditions_fieldlist_of_formfields_as_table(fieldlist, table_id="rulesTable") %}
|
||||||
<table class="fieldlist_formfields pure-table" id="{{ table_id }}">
|
<div class="fieldlist_formfields" id="{{ table_id }}">
|
||||||
<thead>
|
<div class="fieldlist-header">
|
||||||
<tr>
|
|
||||||
{% for subfield in fieldlist[0] %}
|
{% for subfield in fieldlist[0] %}
|
||||||
<th>{{ subfield.label }}</th>
|
<div class="fieldlist-header-cell">{{ subfield.label }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th>Actions</th>
|
<div class="fieldlist-header-cell">Actions</div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="fieldlist-body">
|
||||||
<tbody>
|
|
||||||
{% for form_row in fieldlist %}
|
{% for form_row in fieldlist %}
|
||||||
<tr {% if form_row.errors %} class="error-row" {% endif %}>
|
<div class="fieldlist-row {% if form_row.errors %}error-row{% endif %}">
|
||||||
{% for subfield in form_row %}
|
{% for subfield in form_row %}
|
||||||
<td>
|
<div class="fieldlist-cell">
|
||||||
|
|
||||||
{{ subfield()|safe }}
|
{{ subfield()|safe }}
|
||||||
{% if subfield.errors %}
|
{% if subfield.errors %}
|
||||||
<ul class="errors">
|
<ul class="errors">
|
||||||
@@ -84,17 +83,17 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td>
|
<div class="fieldlist-cell fieldlist-actions">
|
||||||
<button type="button" class="addRuleRow">+</button>
|
<button type="button" class="addRuleRow" title="Add a row/rule after">+</button>
|
||||||
<button type="button" class="removeRuleRow">-</button>
|
<button type="button" class="removeRuleRow" title="Remove this row/rule">-</button>
|
||||||
<button type="button" class="verifyRuleRow" title="Verify this rule against current snapshot">✓</button>
|
<button type="button" class="verifyRuleRow" title="Verify this rule against current snapshot">✓</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<a class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
|
<a class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
|
||||||
<strong>Change</strong>Detection.io</a>
|
<strong>Change</strong>Detection.io</a>
|
||||||
{% else %}
|
{% 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>
|
<strong>Change</strong>Detection.io</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_diff_url %}
|
{% if current_diff_url %}
|
||||||
@@ -157,15 +157,13 @@
|
|||||||
<h4>Try our Chrome extension</h4>
|
<h4>Try our Chrome extension</h4>
|
||||||
<p>
|
<p>
|
||||||
<a id="chrome-extension-link"
|
<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">
|
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')}}">
|
<img alt="Chrome store icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}">
|
||||||
Chrome Webstore
|
Chrome Webstore
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Easily add the current web-page from your browser directly into your changedetection.io tool, more great features coming soon!
|
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>
|
<h4>Changedetection.io needs your support!</h4>
|
||||||
<p>
|
<p>
|
||||||
You can help us by supporting changedetection.io on these platforms;
|
You can help us by supporting changedetection.io on these platforms;
|
||||||
@@ -173,17 +171,20 @@
|
|||||||
<p>
|
<p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<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>
|
AlternativeTo.net</a>
|
||||||
</li>
|
</li>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<li>
|
<li>
|
||||||
And tell your friends and colleagues :)
|
And tell your friends and colleagues :)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_webdriver_type_watches_warning, render_fieldlist_of_formfields_as_table %}
|
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_webdriver_type_watches_warning, render_conditions_fieldlist_of_formfields_as_table %}
|
||||||
{% from '_common_fields.html' import render_common_settings_form %}
|
{% from '_common_fields.html' import render_common_settings_form %}
|
||||||
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
|
||||||
@@ -289,25 +289,13 @@ Math: {{ 1 + 1 }}") }}
|
|||||||
<script>
|
<script>
|
||||||
const verify_condition_rule_url="{{url_for('conditions.verify_condition_single_rule', watch_uuid=uuid)}}";
|
const verify_condition_rule_url="{{url_for('conditions.verify_condition_single_rule', watch_uuid=uuid)}}";
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.verifyRuleRow {
|
|
||||||
background-color: #4caf50;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.verifyRuleRow:hover {
|
|
||||||
background-color: #45a049;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
{{ render_field(form.conditions_match_logic) }}
|
{{ render_field(form.conditions_match_logic) }}
|
||||||
{{ render_fieldlist_of_formfields_as_table(form.conditions) }}
|
{{ render_conditions_fieldlist_of_formfields_as_table(form.conditions) }}
|
||||||
<div class="pure-form-message-inline">
|
<div class="pure-form-message-inline">
|
||||||
<br>
|
|
||||||
Use the verify (✓) button to test if a condition passes against the current snapshot.<br><br>
|
<p id="verify-state-text">Use the verify (✓) button to test if a condition passes against the current snapshot.</p>
|
||||||
Did you know that <strong>conditions</strong> can be extended with your own custom plugin? tutorials coming soon!<br>
|
Read a quick tutorial about <a href="https://changedetection.io/tutorial/conditional-actions-web-page-changes">using conditional web page changes here</a>.<br>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,61 +314,8 @@ Math: {{ 1 + 1 }}") }}
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-control-group">
|
|
||||||
{% set field = render_field(form.include_filters,
|
|
||||||
rows=5,
|
|
||||||
placeholder=has_tag_filters_extra+"#example
|
|
||||||
xpath://body/div/span[contains(@class, 'example-class')]",
|
|
||||||
class="m-d")
|
|
||||||
%}
|
|
||||||
{{ field }}
|
|
||||||
{% if '/text()' in field %}
|
|
||||||
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br>
|
|
||||||
{% endif %}
|
|
||||||
<span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
|
|
||||||
<span data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</span><br>
|
|
||||||
<ul id="advanced-help-selectors" style="display: none;">
|
|
||||||
<li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
|
|
||||||
<li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed).
|
|
||||||
<ul>
|
|
||||||
<li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li>
|
|
||||||
{% if jq_support %}
|
|
||||||
<li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>. Prefix <code>jqraw:</code> outputs the results as text instead of a JSON list.</li>
|
|
||||||
{% else %}
|
|
||||||
<li>jq support not installed</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>XPath - Limit text to this XPath rule, simply start with a forward-slash. To specify XPath to be used explicitly or the XPath rule starts with an XPath function: Prefix with <code>xpath:</code>
|
|
||||||
<ul>
|
|
||||||
<li>Example: <code>//*[contains(@class, 'sametext')]</code> or <code>xpath:count(//*[contains(@class, 'sametext')])</code>, <a
|
|
||||||
href="http://xpather.com/" target="new">test your XPath here</a></li>
|
|
||||||
<li>Example: Get all titles from an RSS feed <code>//title/text()</code></li>
|
|
||||||
<li>To use XPath1.0: Prefix with <code>xpath1:</code></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a
|
|
||||||
href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</span>
|
{% include "edit/include_subtract.html" %}
|
||||||
</div>
|
|
||||||
<fieldset class="pure-control-group">
|
|
||||||
{{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header
|
|
||||||
footer
|
|
||||||
nav
|
|
||||||
.stockticker
|
|
||||||
//*[contains(text(), 'Advertisement')]") }}
|
|
||||||
<span class="pure-form-message-inline">
|
|
||||||
<ul>
|
|
||||||
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
|
|
||||||
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
|
|
||||||
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
</fieldset>
|
|
||||||
<div class="text-filtering border-fieldset">
|
<div class="text-filtering border-fieldset">
|
||||||
<fieldset class="pure-group" id="text-filtering-type-options">
|
<fieldset class="pure-group" id="text-filtering-type-options">
|
||||||
<h3>Text filtering</h3>
|
<h3>Text filtering</h3>
|
||||||
@@ -408,74 +343,7 @@ nav
|
|||||||
{{ render_checkbox_field(form.trim_text_whitespace) }}
|
{{ render_checkbox_field(form.trim_text_whitespace) }}
|
||||||
<span class="pure-form-message-inline">Remove any whitespace before and after each line of text</span>
|
<span class="pure-form-message-inline">Remove any whitespace before and after each line of text</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
{% include "edit/text-options.html" %}
|
||||||
<div class="pure-control-group">
|
|
||||||
{{ render_field(form.trigger_text, rows=5, placeholder="Some text to wait for in a line
|
|
||||||
/some.regex\d{2}/ for case-INsensitive regex
|
|
||||||
") }}
|
|
||||||
<span class="pure-form-message-inline">
|
|
||||||
<ul>
|
|
||||||
<li>Text to wait for before triggering a change/notification, all text and regex are tested <i>case-insensitive</i>.</li>
|
|
||||||
<li>Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li>
|
|
||||||
<li>Each line is processed separately (think of each line as "OR")</li>
|
|
||||||
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="pure-group">
|
|
||||||
{{ render_field(form.ignore_text, rows=5, placeholder="Some text to ignore in a line
|
|
||||||
/some.regex\d{2}/ for case-INsensitive regex
|
|
||||||
") }}
|
|
||||||
<span class="pure-form-message-inline">
|
|
||||||
<ul>
|
|
||||||
<li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li>
|
|
||||||
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
|
|
||||||
<li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
|
|
||||||
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<div class="pure-control-group">
|
|
||||||
{{ render_field(form.text_should_not_be_present, rows=5, placeholder="For example: Out of stock
|
|
||||||
Sold out
|
|
||||||
Not in stock
|
|
||||||
Unavailable") }}
|
|
||||||
<span class="pure-form-message-inline">
|
|
||||||
<ul>
|
|
||||||
<li>Block change-detection while this text is on the page, all text and regex are tested <i>case-insensitive</i>, good for waiting for when a product is available again</li>
|
|
||||||
<li>Block text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li>
|
|
||||||
<li>All lines here must not exist (think of each line as "OR")</li>
|
|
||||||
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset>
|
|
||||||
<div class="pure-control-group">
|
|
||||||
{{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/
|
|
||||||
or
|
|
||||||
keyword") }}
|
|
||||||
<span class="pure-form-message-inline">
|
|
||||||
<ul>
|
|
||||||
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
|
|
||||||
<ul>
|
|
||||||
<li>Regular expression ‐ example <code>/reports.+?2022/i</code></li>
|
|
||||||
<li>Don't forget to consider the white-space at the start of a line <code>/.+?reports.+?2022/i</code></li>
|
|
||||||
<li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br></li>
|
|
||||||
<li>Keyword example ‐ example <code>Out of stock</code></li>
|
|
||||||
<li>Use groups to extract just that text ‐ example <code>/reports.+?(\d+)/i</code> returns a list of years only</li>
|
|
||||||
<li>Example - match lines containing a keyword <code>/.*icecream.*/</code></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>One line per regular-expression/string match</li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="text-preview" style="display: none;" >
|
<div id="text-preview" style="display: none;" >
|
||||||
@@ -575,6 +443,10 @@ keyword") }}
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h4>Text similarity</h4>
|
||||||
|
<p><strong>Levenshtein Distance</strong> - Last 2 snapshots: {{ lev_info }}</p>
|
||||||
|
<p style="max-width: 80%; font-size: 80%"><strong>Levenshtein Distance</strong> Calculates the minimum number of insertions, deletions, and substitutions required to change one text into the other.</p>
|
||||||
{% if watch.history_n %}
|
{% if watch.history_n %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{{url_for('ui.ui_edit.watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">Download latest HTML snapshot</a>
|
<a href="{{url_for('ui.ui_edit.watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">Download latest HTML snapshot</a>
|
||||||
@@ -588,10 +460,10 @@ keyword") }}
|
|||||||
{{ render_button(form.save_button) }}
|
{{ render_button(form.save_button) }}
|
||||||
<a href="{{url_for('ui.form_delete', uuid=uuid)}}"
|
<a href="{{url_for('ui.form_delete', uuid=uuid)}}"
|
||||||
class="pure-button button-small button-error ">Delete</a>
|
class="pure-button button-small button-error ">Delete</a>
|
||||||
<a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}"
|
{% if watch.history_n %}<a href="{{url_for('ui.clear_watch_history', uuid=uuid)}}"
|
||||||
class="pure-button button-small button-error ">Clear History</a>
|
class="pure-button button-small button-error ">Clear History</a>{% endif %}
|
||||||
<a href="{{url_for('ui.form_clone', uuid=uuid)}}"
|
<a href="{{url_for('ui.form_clone', uuid=uuid)}}"
|
||||||
class="pure-button button-small ">Create Copy</a>
|
class="pure-button button-small ">Clone & Edit</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
55
changedetectionio/templates/edit/include_subtract.html
Normal file
55
changedetectionio/templates/edit/include_subtract.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<div class="pure-control-group">
|
||||||
|
{% set field = render_field(form.include_filters,
|
||||||
|
rows=5,
|
||||||
|
placeholder=has_tag_filters_extra+"#example
|
||||||
|
xpath://body/div/span[contains(@class, 'example-class')]",
|
||||||
|
class="m-d")
|
||||||
|
%}
|
||||||
|
{{ field }}
|
||||||
|
{% if '/text()' in field %}
|
||||||
|
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br>
|
||||||
|
{% endif %}
|
||||||
|
<span class="pure-form-message-inline">One CSS, xPath 1 & 2, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
|
||||||
|
<span data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</span><br>
|
||||||
|
<ul id="advanced-help-selectors" style="display: none;">
|
||||||
|
<li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
|
||||||
|
<li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed).
|
||||||
|
<ul>
|
||||||
|
<li>JSONPath: Prefix with <code>json:</code>, use <code>json:$</code> to force re-formatting if required, <a href="https://jsonpath.com/" target="new">test your JSONPath here</a>.</li>
|
||||||
|
{% if jq_support %}
|
||||||
|
<li>jq: Prefix with <code>jq:</code> and <a href="https://jqplay.org/" target="new">test your jq here</a>. Using <a href="https://stedolan.github.io/jq/" target="new">jq</a> allows for complex filtering and processing of JSON data with built-in functions, regex, filtering, and more. See examples and documentation <a href="https://stedolan.github.io/jq/manual/" target="new">here</a>. Prefix <code>jqraw:</code> outputs the results as text instead of a JSON list.</li>
|
||||||
|
{% else %}
|
||||||
|
<li>jq support not installed</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>XPath - Limit text to this XPath rule, simply start with a forward-slash. To specify XPath to be used explicitly or the XPath rule starts with an XPath function: Prefix with <code>xpath:</code>
|
||||||
|
<ul>
|
||||||
|
<li>Example: <code>//*[contains(@class, 'sametext')]</code> or <code>xpath:count(//*[contains(@class, 'sametext')])</code>, <a
|
||||||
|
href="http://xpather.com/" target="new">test your XPath here</a></li>
|
||||||
|
<li>Example: Get all titles from an RSS feed <code>//title/text()</code></li>
|
||||||
|
<li>To use XPath1.0: Prefix with <code>xpath1:</code></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a
|
||||||
|
href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<fieldset class="pure-control-group">
|
||||||
|
{{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header
|
||||||
|
footer
|
||||||
|
nav
|
||||||
|
.stockticker
|
||||||
|
//*[contains(text(), 'Advertisement')]") }}
|
||||||
|
<span class="pure-form-message-inline">
|
||||||
|
<ul>
|
||||||
|
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
|
||||||
|
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
|
||||||
|
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</fieldset>
|
||||||
69
changedetectionio/templates/edit/text-options.html
Normal file
69
changedetectionio/templates/edit/text-options.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group">
|
||||||
|
{{ render_field(form.trigger_text, rows=5, placeholder="Some text to wait for in a line
|
||||||
|
/some.regex\d{2}/ for case-INsensitive regex
|
||||||
|
") }}
|
||||||
|
<span class="pure-form-message-inline">
|
||||||
|
<ul>
|
||||||
|
<li>Text to wait for before triggering a change/notification, all text and regex are tested <i>case-insensitive</i>.</li>
|
||||||
|
<li>Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li>
|
||||||
|
<li>Each line is processed separately (think of each line as "OR")</li>
|
||||||
|
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="pure-group">
|
||||||
|
{{ render_field(form.ignore_text, rows=5, placeholder="Some text to ignore in a line
|
||||||
|
/some.regex\d{2}/ for case-INsensitive regex
|
||||||
|
") }}
|
||||||
|
<span class="pure-form-message-inline">
|
||||||
|
<ul>
|
||||||
|
<li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li>
|
||||||
|
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
|
||||||
|
<li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
|
||||||
|
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group">
|
||||||
|
{{ render_field(form.text_should_not_be_present, rows=5, placeholder="For example: Out of stock
|
||||||
|
Sold out
|
||||||
|
Not in stock
|
||||||
|
Unavailable") }}
|
||||||
|
<span class="pure-form-message-inline">
|
||||||
|
<ul>
|
||||||
|
<li>Block change-detection while this text is on the page, all text and regex are tested <i>case-insensitive</i>, good for waiting for when a product is available again</li>
|
||||||
|
<li>Block text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li>
|
||||||
|
<li>All lines here must not exist (think of each line as "OR")</li>
|
||||||
|
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div class="pure-control-group">
|
||||||
|
{{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/
|
||||||
|
or
|
||||||
|
keyword") }}
|
||||||
|
<span class="pure-form-message-inline">
|
||||||
|
<ul>
|
||||||
|
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
|
||||||
|
<ul>
|
||||||
|
<li>Regular expression ‐ example <code>/reports.+?2022/i</code></li>
|
||||||
|
<li>Don't forget to consider the white-space at the start of a line <code>/.+?reports.+?2022/i</code></li>
|
||||||
|
<li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br></li>
|
||||||
|
<li>Keyword example ‐ example <code>Out of stock</code></li>
|
||||||
|
<li>Use groups to extract just that text ‐ example <code>/reports.+?(\d+)/i</code> returns a list of years only</li>
|
||||||
|
<li>Example - match lines containing a keyword <code>/.*icecream.*/</code></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>One line per regular-expression/string match</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
24
changedetectionio/tests/apprise/test_apprise_asset.py
Normal file
24
changedetectionio/tests/apprise/test_apprise_asset.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import pytest
|
||||||
|
from apprise import AppriseAsset
|
||||||
|
|
||||||
|
from changedetectionio.apprise_asset import (
|
||||||
|
APPRISE_APP_DESC,
|
||||||
|
APPRISE_APP_ID,
|
||||||
|
APPRISE_APP_URL,
|
||||||
|
APPRISE_AVATAR_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def apprise_asset() -> AppriseAsset:
|
||||||
|
from changedetectionio.apprise_asset import apprise_asset
|
||||||
|
|
||||||
|
return apprise_asset
|
||||||
|
|
||||||
|
|
||||||
|
def test_apprise_asset_init(apprise_asset: AppriseAsset):
|
||||||
|
assert isinstance(apprise_asset, AppriseAsset)
|
||||||
|
assert apprise_asset.app_id == APPRISE_APP_ID
|
||||||
|
assert apprise_asset.app_desc == APPRISE_APP_DESC
|
||||||
|
assert apprise_asset.app_url == APPRISE_APP_URL
|
||||||
|
assert apprise_asset.image_url_logo == APPRISE_AVATAR_URL
|
||||||
211
changedetectionio/tests/apprise/test_apprise_custom_api_call.py
Normal file
211
changedetectionio/tests/apprise/test_apprise_custom_api_call.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from apprise.utils.parse import parse_url as apprise_parse_url
|
||||||
|
|
||||||
|
from ...apprise_plugin.custom_handlers import (
|
||||||
|
_get_auth,
|
||||||
|
_get_headers,
|
||||||
|
_get_params,
|
||||||
|
apprise_http_custom_handler,
|
||||||
|
SUPPORTED_HTTP_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url,expected_auth",
|
||||||
|
[
|
||||||
|
("get://user:pass@localhost:9999", ("user", "pass")),
|
||||||
|
("get://user@localhost:9999", "user"),
|
||||||
|
("get://localhost:9999", ""),
|
||||||
|
("get://user%20name:pass%20word@localhost:9999", ("user name", "pass word")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_auth(url, expected_auth):
|
||||||
|
"""Test authentication extraction with various URL formats."""
|
||||||
|
parsed_url = apprise_parse_url(url)
|
||||||
|
assert _get_auth(parsed_url) == expected_auth
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url,body,expected_content_type",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"get://localhost:9999?+content-type=application/xml",
|
||||||
|
"test",
|
||||||
|
"application/xml",
|
||||||
|
),
|
||||||
|
("get://localhost:9999", '{"key": "value"}', "application/json; charset=utf-8"),
|
||||||
|
("get://localhost:9999", "plain text", None),
|
||||||
|
("get://localhost:9999?+content-type=text/plain", "test", "text/plain"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_headers(url, body, expected_content_type):
|
||||||
|
"""Test header extraction and content type detection."""
|
||||||
|
parsed_url = apprise_parse_url(url)
|
||||||
|
headers = _get_headers(parsed_url, body)
|
||||||
|
|
||||||
|
if expected_content_type:
|
||||||
|
assert headers.get("Content-Type") == expected_content_type
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url,expected_params",
|
||||||
|
[
|
||||||
|
("get://localhost:9999?param1=value1", {"param1": "value1"}),
|
||||||
|
("get://localhost:9999?param1=value1&-param2=ignored", {"param1": "value1"}),
|
||||||
|
("get://localhost:9999?param1=value1&+header=test", {"param1": "value1"}),
|
||||||
|
(
|
||||||
|
"get://localhost:9999?encoded%20param=encoded%20value",
|
||||||
|
{"encoded param": "encoded value"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_params(url, expected_params):
|
||||||
|
"""Test parameter extraction with URL encoding and exclusion logic."""
|
||||||
|
parsed_url = apprise_parse_url(url)
|
||||||
|
params = _get_params(parsed_url)
|
||||||
|
assert dict(params) == expected_params
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url,schema,method",
|
||||||
|
[
|
||||||
|
("get://localhost:9999", "get", "GET"),
|
||||||
|
("post://localhost:9999", "post", "POST"),
|
||||||
|
("delete://localhost:9999", "delete", "DELETE"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("requests.request")
|
||||||
|
def test_apprise_custom_api_call_success(mock_request, url, schema, method):
|
||||||
|
"""Test successful API calls with different HTTP methods and schemas."""
|
||||||
|
mock_request.return_value.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
meta = {"url": url, "schema": schema}
|
||||||
|
result = apprise_http_custom_handler(
|
||||||
|
body="test body", title="Test Title", notify_type="info", meta=meta
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
|
||||||
|
call_args = mock_request.call_args
|
||||||
|
assert call_args[1]["method"] == method.upper()
|
||||||
|
assert call_args[1]["url"].startswith("http")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.request")
|
||||||
|
def test_apprise_custom_api_call_with_auth(mock_request):
|
||||||
|
"""Test API call with authentication."""
|
||||||
|
mock_request.return_value.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
url = "get://user:pass@localhost:9999/secure"
|
||||||
|
meta = {"url": url, "schema": "get"}
|
||||||
|
|
||||||
|
result = apprise_http_custom_handler(
|
||||||
|
body=json.dumps({"key": "value"}),
|
||||||
|
title="Secure Test",
|
||||||
|
notify_type="info",
|
||||||
|
meta=meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
call_args = mock_request.call_args
|
||||||
|
assert call_args[1]["auth"] == ("user", "pass")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"exception_type,expected_result",
|
||||||
|
[
|
||||||
|
(requests.RequestException, False),
|
||||||
|
(requests.HTTPError, False),
|
||||||
|
(Exception, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("requests.request")
|
||||||
|
def test_apprise_custom_api_call_failure(mock_request, exception_type, expected_result):
|
||||||
|
"""Test various failure scenarios."""
|
||||||
|
url = "get://localhost:9999/error"
|
||||||
|
meta = {"url": url, "schema": "get"}
|
||||||
|
|
||||||
|
# Simulate different types of exceptions
|
||||||
|
mock_request.side_effect = exception_type("Error occurred")
|
||||||
|
|
||||||
|
result = apprise_http_custom_handler(
|
||||||
|
body="error body", title="Error Test", notify_type="error", meta=meta
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_url_parsing():
|
||||||
|
"""Test handling of invalid URL parsing."""
|
||||||
|
meta = {"url": "invalid://url", "schema": "invalid"}
|
||||||
|
result = apprise_http_custom_handler(
|
||||||
|
body="test", title="Invalid URL", notify_type="info", meta=meta
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"schema,expected_method",
|
||||||
|
[
|
||||||
|
(http_method, http_method.upper())
|
||||||
|
for http_method in SUPPORTED_HTTP_METHODS
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("requests.request")
|
||||||
|
def test_http_methods(mock_request, schema, expected_method):
|
||||||
|
"""Test all supported HTTP methods."""
|
||||||
|
mock_request.return_value.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
url = f"{schema}://localhost:9999"
|
||||||
|
|
||||||
|
result = apprise_http_custom_handler(
|
||||||
|
body="test body",
|
||||||
|
title="Test Title",
|
||||||
|
notify_type="info",
|
||||||
|
meta={"url": url, "schema": schema},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
|
||||||
|
call_args = mock_request.call_args
|
||||||
|
assert call_args[1]["method"] == expected_method
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_schema,expected_method",
|
||||||
|
[
|
||||||
|
(f"{http_method}s", http_method.upper())
|
||||||
|
for http_method in SUPPORTED_HTTP_METHODS
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("requests.request")
|
||||||
|
def test_https_method_conversion(
|
||||||
|
mock_request, input_schema, expected_method
|
||||||
|
):
|
||||||
|
"""Validate that methods ending with 's' use HTTPS and correct HTTP method."""
|
||||||
|
mock_request.return_value.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
url = f"{input_schema}://localhost:9999"
|
||||||
|
|
||||||
|
result = apprise_http_custom_handler(
|
||||||
|
body="test body",
|
||||||
|
title="Test Title",
|
||||||
|
notify_type="info",
|
||||||
|
meta={"url": url, "schema": input_schema},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
|
||||||
|
call_args = mock_request.call_args
|
||||||
|
|
||||||
|
assert call_args[1]["method"] == expected_method
|
||||||
|
assert call_args[1]["url"].startswith("https")
|
||||||
@@ -36,7 +36,7 @@ def test_select_custom(client, live_server, measure_memory_usage):
|
|||||||
assert b"1 Imported" in res.data
|
assert b"1 Imported" in res.data
|
||||||
wait_for_all_checks(client)
|
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
|
assert b'Proxy Authentication Required' not in res.data
|
||||||
|
|
||||||
res = client.get(
|
res = client.get(
|
||||||
|
|||||||
@@ -83,14 +83,14 @@ def test_restock_detection(client, live_server, measure_memory_usage):
|
|||||||
|
|
||||||
# Is it correctly show as NOT in stock?
|
# Is it correctly show as NOT in stock?
|
||||||
wait_for_all_checks(client)
|
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
|
assert b'not-in-stock' in res.data
|
||||||
|
|
||||||
# Is it correctly shown as in stock
|
# Is it correctly shown as in stock
|
||||||
set_back_in_stock_response()
|
set_back_in_stock_response()
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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
|
assert b'not-in-stock' not in res.data
|
||||||
|
|
||||||
# We should have a notification
|
# 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"
|
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"
|
# 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"
|
assert b'not-in-stock' in res.data, "Correctly showing NOT IN STOCK in the list after it changed from IN STOCK"
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ def test_setup(live_server):
|
|||||||
|
|
||||||
def get_last_message_from_smtp_server():
|
def get_last_message_from_smtp_server():
|
||||||
import socket
|
import socket
|
||||||
global smtp_test_server
|
|
||||||
port = 11080 # socket server port number
|
port = 11080 # socket server port number
|
||||||
|
|
||||||
client_socket = socket.socket() # instantiate
|
client_socket = socket.socket() # instantiate
|
||||||
@@ -44,7 +43,6 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
|
|||||||
# live_server_setup(live_server)
|
# live_server_setup(live_server)
|
||||||
set_original_response()
|
set_original_response()
|
||||||
|
|
||||||
global smtp_test_server
|
|
||||||
notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
|
notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
@@ -99,7 +97,6 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
|
|||||||
# https://github.com/caronc/apprise/issues/633
|
# https://github.com/caronc/apprise/issues/633
|
||||||
|
|
||||||
set_original_response()
|
set_original_response()
|
||||||
global smtp_test_server
|
|
||||||
notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
|
notification_url = f'mailto://changedetection@{smtp_test_server}:11025/?to=fff@home.com'
|
||||||
notification_body = f"""<!DOCTYPE html>
|
notification_body = f"""<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .util import live_server_setup
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ def test_check_access_control(app, client, live_server):
|
|||||||
assert b"Password protection enabled." in res.data
|
assert b"Password protection enabled." in res.data
|
||||||
|
|
||||||
# Check we hit the login
|
# 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
|
# Should be logged out
|
||||||
assert b"Login" in res.data
|
assert b"Login" in res.data
|
||||||
|
|
||||||
@@ -52,6 +52,19 @@ def test_check_access_control(app, client, live_server):
|
|||||||
res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
|
res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
|
||||||
assert b'Random content' in res.data
|
assert b'Random content' in res.data
|
||||||
|
|
||||||
|
# access to assets should work (check_authentication)
|
||||||
|
res = c.get(url_for('static_content', group='js', filename='jquery-3.6.0.min.js'))
|
||||||
|
assert res.status_code == 200
|
||||||
|
res = c.get(url_for('static_content', group='styles', filename='styles.css'))
|
||||||
|
assert res.status_code == 200
|
||||||
|
res = c.get(url_for('static_content', group='styles', filename='404-testetest.css'))
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
# Access to screenshots should be limited by 'shared_diff_access'
|
||||||
|
path = url_for('static_content', group='screenshot', filename='random-uuid-that-will-404.png', _external=True)
|
||||||
|
res = c.get(path)
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
# Check wrong password does not let us in
|
# Check wrong password does not let us in
|
||||||
res = c.post(
|
res = c.post(
|
||||||
url_for("login"),
|
url_for("login"),
|
||||||
@@ -155,7 +168,7 @@ def test_check_access_control(app, client, live_server):
|
|||||||
url_for("settings.settings_page"),
|
url_for("settings.settings_page"),
|
||||||
data={"application-password": "foobar",
|
data={"application-password": "foobar",
|
||||||
# Should be disabled
|
# Should be disabled
|
||||||
# "application-shared_diff_access": "True",
|
"application-shared_diff_access": "",
|
||||||
"requests-time_between_check-minutes": 180,
|
"requests-time_between_check-minutes": 180,
|
||||||
'application-fetch_backend': "html_requests"},
|
'application-fetch_backend': "html_requests"},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
@@ -164,10 +177,14 @@ def test_check_access_control(app, client, live_server):
|
|||||||
assert b"Password protection enabled." in res.data
|
assert b"Password protection enabled." in res.data
|
||||||
|
|
||||||
# Check we hit the login
|
# 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
|
# Should be logged out
|
||||||
assert b"Login" in res.data
|
assert b"Login" in res.data
|
||||||
|
|
||||||
|
# Access to screenshots should be limited by 'shared_diff_access'
|
||||||
|
res = c.get(url_for('static_content', group='screenshot', filename='random-uuid-that-will-403.png'))
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
# The diff page should return something valid when logged out
|
# The diff page should return something valid when logged out
|
||||||
res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
|
res = c.get(url_for("ui.ui_views.diff_history_page", uuid="first"))
|
||||||
assert b'Random content' not in res.data
|
assert b'Random content' not in res.data
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory
|
|||||||
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
assert b'Queued 1 watch for rechecking.' in res.data
|
assert b'Queued 1 watch for rechecking.' in res.data
|
||||||
wait_for_all_checks(client)
|
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
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
# The trigger line is REMOVED, this should trigger
|
# 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.
|
# 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)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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'unviewed' in res.data
|
||||||
|
|
||||||
|
|
||||||
@@ -90,14 +90,14 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory
|
|||||||
set_original(excluding=None)
|
set_original(excluding=None)
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
# Remove it again, and we should get a trigger
|
# Remove it again, and we should get a trigger
|
||||||
set_original(excluding='The golden line')
|
set_original(excluding='The golden line')
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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'unviewed' in res.data
|
||||||
|
|
||||||
res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
|
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
|
assert b'Queued 1 watch for rechecking.' in res.data
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
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
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
# The trigger line is ADDED, this should trigger
|
# The trigger line is ADDED, this should trigger
|
||||||
set_original(add_line='<p>Oh yes please</p>')
|
set_original(add_line='<p>Oh yes please</p>')
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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'unviewed' in res.data
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
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 json
|
||||||
import uuid
|
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):
|
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
|
# Create a watch
|
||||||
set_original_response()
|
set_original_response()
|
||||||
|
|
||||||
# Validate bad URL
|
# Validate bad URL
|
||||||
test_url = url_for('test_endpoint', _external=True,
|
test_url = url_for('test_endpoint', _external=True )
|
||||||
headers={'x-api-key': api_key}, )
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("createwatch"),
|
url_for("createwatch"),
|
||||||
data=json.dumps({"url": "h://xxxxxxxxxom"}),
|
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):
|
def test_api_watch_PUT_update(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
|
# Create a watch
|
||||||
set_original_response()
|
set_original_response()
|
||||||
test_url = url_for('test_endpoint', _external=True,
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
headers={'x-api-key': api_key}, )
|
|
||||||
|
|
||||||
# Create new
|
# Create new
|
||||||
res = client.post(
|
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):
|
def test_api_import(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')
|
||||||
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("import") + "?tag=import-test",
|
url_for("import") + "?tag=import-test",
|
||||||
@@ -385,7 +383,7 @@ def test_api_import(client, live_server, measure_memory_usage):
|
|||||||
|
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert len(res.json) == 2
|
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://website1.com" in res.data
|
||||||
assert b"https://website2.com" in res.data
|
assert b"https://website2.com" in res.data
|
||||||
|
|
||||||
@@ -393,3 +391,47 @@ def test_api_import(client, live_server, measure_memory_usage):
|
|||||||
res = client.get(url_for('tags.tags_overview_page'))
|
res = client.get(url_for('tags.tags_overview_page'))
|
||||||
assert b'import-test' in res.data
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
108
changedetectionio/tests/test_api_notifications.py
Normal file
108
changedetectionio/tests/test_api_notifications.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
from .util import live_server_setup
|
||||||
|
import json
|
||||||
|
|
||||||
|
def test_api_notifications_crud(client, live_server):
|
||||||
|
live_server_setup(live_server)
|
||||||
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
|
# Confirm notifications are initially empty
|
||||||
|
res = client.get(
|
||||||
|
url_for("notifications"),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json == {"notification_urls": []}
|
||||||
|
|
||||||
|
# Add notification URLs
|
||||||
|
test_urls = ["posts://example.com/notify1", "posts://example.com/notify2"]
|
||||||
|
res = client.post(
|
||||||
|
url_for("notifications"),
|
||||||
|
data=json.dumps({"notification_urls": test_urls}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
for url in test_urls:
|
||||||
|
assert url in res.json["notification_urls"]
|
||||||
|
|
||||||
|
# Confirm the notification URLs were added
|
||||||
|
res = client.get(
|
||||||
|
url_for("notifications"),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
for url in test_urls:
|
||||||
|
assert url in res.json["notification_urls"]
|
||||||
|
|
||||||
|
# Delete one notification URL
|
||||||
|
res = client.delete(
|
||||||
|
url_for("notifications"),
|
||||||
|
data=json.dumps({"notification_urls": [test_urls[0]]}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 204
|
||||||
|
|
||||||
|
# Confirm it was removed and the other remains
|
||||||
|
res = client.get(
|
||||||
|
url_for("notifications"),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert test_urls[0] not in res.json["notification_urls"]
|
||||||
|
assert test_urls[1] in res.json["notification_urls"]
|
||||||
|
|
||||||
|
# Try deleting a non-existent URL
|
||||||
|
res = client.delete(
|
||||||
|
url_for("notifications"),
|
||||||
|
data=json.dumps({"notification_urls": ["posts://nonexistent.com"]}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
res = client.post(
|
||||||
|
url_for("notifications"),
|
||||||
|
data=json.dumps({"notification_urls": test_urls}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
|
||||||
|
# Replace with a new list
|
||||||
|
replacement_urls = ["posts://new.example.com"]
|
||||||
|
res = client.put(
|
||||||
|
url_for("notifications"),
|
||||||
|
data=json.dumps({"notification_urls": replacement_urls}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json["notification_urls"] == replacement_urls
|
||||||
|
|
||||||
|
# Replace with an empty list
|
||||||
|
res = client.put(
|
||||||
|
url_for("notifications"),
|
||||||
|
data=json.dumps({"notification_urls": []}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json["notification_urls"] == []
|
||||||
|
|
||||||
|
# Provide an invalid AppRise URL to trigger validation error
|
||||||
|
invalid_urls = ["ftp://not-app-rise"]
|
||||||
|
res = client.post(
|
||||||
|
url_for("notifications"),
|
||||||
|
data=json.dumps({"notification_urls": invalid_urls}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 400
|
||||||
|
assert "is not a valid AppRise URL." in res.data.decode()
|
||||||
|
|
||||||
|
res = client.put(
|
||||||
|
url_for("notifications"),
|
||||||
|
data=json.dumps({"notification_urls": invalid_urls}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 400
|
||||||
|
assert "is not a valid AppRise URL." in res.data.decode()
|
||||||
|
|
||||||
|
|
||||||
101
changedetectionio/tests/test_api_search.py
Normal file
101
changedetectionio/tests/test_api_search.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from copy import copy
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_search(client, live_server):
|
||||||
|
live_server_setup(live_server)
|
||||||
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
|
watch_data = {}
|
||||||
|
# Add some test watches
|
||||||
|
urls = [
|
||||||
|
'https://example.com/page1',
|
||||||
|
'https://example.org/testing',
|
||||||
|
'https://test-site.com/example'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Import the test URLs
|
||||||
|
res = client.post(
|
||||||
|
url_for("imports.import_page"),
|
||||||
|
data={"urls": "\r\n".join(urls)},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
assert b"3 Imported" in res.data
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
# Get a listing, it will be the first one
|
||||||
|
watches_response = client.get(
|
||||||
|
url_for("createwatch"),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Add a title to one watch for title search testing
|
||||||
|
for uuid, watch in watches_response.json.items():
|
||||||
|
|
||||||
|
watch_data = client.get(url_for("watch", uuid=uuid),
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
|
||||||
|
if urls[0] == watch_data.json['url']:
|
||||||
|
# HTTP PUT ( UPDATE an existing watch )
|
||||||
|
client.put(
|
||||||
|
url_for("watch", uuid=uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({'title': 'Example Title Test'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test search by URL
|
||||||
|
res = client.get(url_for("search")+"?q=https://example.com/page1", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 1
|
||||||
|
assert list(res.json.values())[0]['url'] == urls[0]
|
||||||
|
|
||||||
|
# Test search by URL - partial should NOT match without ?partial=true flag
|
||||||
|
res = client.get(url_for("search")+"?q=https://example", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# Test search by title
|
||||||
|
res = client.get(url_for("search")+"?q=Example Title Test", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 1
|
||||||
|
assert list(res.json.values())[0]['url'] == urls[0]
|
||||||
|
assert list(res.json.values())[0]['title'] == 'Example Title Test'
|
||||||
|
|
||||||
|
# Test search that should return multiple results (partial = true)
|
||||||
|
res = client.get(url_for("search")+"?q=https://example&partial=true", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 2
|
||||||
|
|
||||||
|
# Test empty search
|
||||||
|
res = client.get(url_for("search")+"?q=", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
# Add a tag to test search with tag filter
|
||||||
|
tag_name = 'test-tag'
|
||||||
|
res = client.post(
|
||||||
|
url_for("tag"),
|
||||||
|
data=json.dumps({"title": tag_name}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
tag_uuid = res.json['uuid']
|
||||||
|
|
||||||
|
# Add the tag to one watch
|
||||||
|
for uuid, watch in watches_response.json.items():
|
||||||
|
if urls[2] == watch['url']:
|
||||||
|
client.put(
|
||||||
|
url_for("watch", uuid=uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({'tags': [tag_uuid]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Test search with tag filter and q
|
||||||
|
res = client.get(url_for("search") + f"?q={urls[2]}&tag={tag_name}", headers={'x-api-key': api_key, 'content-type': 'application/json'})
|
||||||
|
assert len(res.json) == 1
|
||||||
|
assert list(res.json.values())[0]['url'] == urls[2]
|
||||||
|
|
||||||
143
changedetectionio/tests/test_api_tags.py
Normal file
143
changedetectionio/tests/test_api_tags.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
|
import json
|
||||||
|
|
||||||
|
def test_api_tags_listing(client, live_server, measure_memory_usage):
|
||||||
|
live_server_setup(live_server)
|
||||||
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
tag_title = 'Test Tag'
|
||||||
|
|
||||||
|
# Get a listing
|
||||||
|
res = client.get(
|
||||||
|
url_for("tags"),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.text.strip() == "{}", "Should be empty list"
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
res = client.post(
|
||||||
|
url_for("tag"),
|
||||||
|
data=json.dumps({"title": tag_title}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
|
||||||
|
new_tag_uuid = res.json.get('uuid')
|
||||||
|
|
||||||
|
# List tags - should include our new tag
|
||||||
|
res = client.get(
|
||||||
|
url_for("tags"),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert new_tag_uuid in res.text
|
||||||
|
assert res.json[new_tag_uuid]['title'] == tag_title
|
||||||
|
assert res.json[new_tag_uuid]['notification_muted'] == False
|
||||||
|
|
||||||
|
# Get single tag
|
||||||
|
res = client.get(
|
||||||
|
url_for("tag", uuid=new_tag_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json['title'] == tag_title
|
||||||
|
|
||||||
|
# Update tag
|
||||||
|
res = client.put(
|
||||||
|
url_for("tag", uuid=new_tag_uuid),
|
||||||
|
data=json.dumps({"title": "Updated Tag"}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b'OK' in res.data
|
||||||
|
|
||||||
|
# Verify update worked
|
||||||
|
res = client.get(
|
||||||
|
url_for("tag", uuid=new_tag_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json['title'] == 'Updated Tag'
|
||||||
|
|
||||||
|
# Mute tag notifications
|
||||||
|
res = client.get(
|
||||||
|
url_for("tag", uuid=new_tag_uuid) + "?muted=muted",
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b'OK' in res.data
|
||||||
|
|
||||||
|
# Verify muted status
|
||||||
|
res = client.get(
|
||||||
|
url_for("tag", uuid=new_tag_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json['notification_muted'] == True
|
||||||
|
|
||||||
|
# Unmute tag
|
||||||
|
res = client.get(
|
||||||
|
url_for("tag", uuid=new_tag_uuid) + "?muted=unmuted",
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b'OK' in res.data
|
||||||
|
|
||||||
|
# Verify unmuted status
|
||||||
|
res = client.get(
|
||||||
|
url_for("tag", uuid=new_tag_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json['notification_muted'] == False
|
||||||
|
|
||||||
|
# Create a watch with the tag and check it matches UUID
|
||||||
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
|
res = client.post(
|
||||||
|
url_for("createwatch"),
|
||||||
|
data=json.dumps({"url": test_url, "tag": "Updated Tag", "title": "Watch with tag"}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
watch_uuid = res.json.get('uuid')
|
||||||
|
|
||||||
|
# Verify tag is associated with watch by name if need be
|
||||||
|
res = client.get(
|
||||||
|
url_for("watch", uuid=watch_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert new_tag_uuid in res.json.get('tags', [])
|
||||||
|
|
||||||
|
# Delete tag
|
||||||
|
res = client.delete(
|
||||||
|
url_for("tag", uuid=new_tag_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 204
|
||||||
|
|
||||||
|
# Verify tag is gone
|
||||||
|
res = client.get(
|
||||||
|
url_for("tags"),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert new_tag_uuid not in res.text
|
||||||
|
|
||||||
|
# Verify tag was removed from watch
|
||||||
|
res = client.get(
|
||||||
|
url_for("watch", uuid=watch_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert new_tag_uuid not in res.json.get('tags', [])
|
||||||
|
|
||||||
|
# Delete the watch
|
||||||
|
res = client.delete(
|
||||||
|
url_for("watch", uuid=watch_uuid),
|
||||||
|
headers={'x-api-key': api_key},
|
||||||
|
)
|
||||||
|
assert res.status_code == 204
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
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():
|
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)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# Should get a notice that it's available
|
# 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
|
assert b'ldjson-price-track-offer' in res.data
|
||||||
|
|
||||||
# Accept it
|
# 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)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
# Offer should be gone
|
# 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'Embedded price data' not in res.data
|
||||||
assert b'tracking-ldjson-price-data' in res.data
|
assert b'tracking-ldjson-price-data' in res.data
|
||||||
|
|
||||||
# and last snapshop (via API) should be just the price
|
# 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(
|
res = client.get(
|
||||||
url_for("watchsinglehistory", uuid=uuid, timestamp='latest'),
|
url_for("watchsinglehistory", uuid=uuid, timestamp='latest'),
|
||||||
headers={'x-api-key': api_key},
|
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
|
assert b"1 Imported" in res.data
|
||||||
wait_for_all_checks(client)
|
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
|
assert b'ldjson-price-track-offer' not in res.data
|
||||||
|
|
||||||
##########################################################################################
|
##########################################################################################
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
|||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# It should report nothing found (no new 'unviewed' class)
|
# 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'unviewed' not in res.data
|
||||||
assert b'test-endpoint' in res.data
|
assert b'test-endpoint' in res.data
|
||||||
|
|
||||||
@@ -74,8 +74,13 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
|||||||
res = client.get(url_for("ui.ui_edit.watch_get_latest_html", uuid=uuid))
|
res = client.get(url_for("ui.ui_edit.watch_get_latest_html", uuid=uuid))
|
||||||
assert b'which has this one new line' in res.data
|
assert b'which has this one new line' in res.data
|
||||||
|
|
||||||
|
# Check the 'levenshtein' distance calc showed something useful
|
||||||
|
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
|
||||||
|
assert b'Last 2 snapshots: 17' in res.data
|
||||||
|
|
||||||
|
|
||||||
# Now something should be ready, indicated by having a 'unviewed' class
|
# 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
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
# #75, and it should be in the RSS feed
|
# #75, and it should be in the RSS feed
|
||||||
@@ -112,7 +117,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
|||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# It should report nothing found (no new 'unviewed' class)
|
# 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'unviewed' not in res.data
|
||||||
assert b'Mark all viewed' 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
|
assert b'head title' not in res.data # Should not be present because this is off by default
|
||||||
@@ -131,7 +136,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
|||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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'unviewed' in res.data
|
||||||
assert b'Mark all viewed' in res.data
|
assert b'Mark all viewed' in res.data
|
||||||
|
|
||||||
@@ -151,7 +156,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.clear_watch_history", uuid=uuid))
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("watchlist.index"))
|
||||||
assert b'preview/' in res.data
|
assert b'preview/' in res.data
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
|
|||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# It should report nothing found (no new 'unviewed' class)
|
# 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'unviewed' not in res.data
|
||||||
assert b'/test-endpoint' 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)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# It should report nothing found (no new 'unviewed' class)
|
# 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'unviewed' not in res.data
|
||||||
assert b'/test-endpoint' 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()
|
set_original_ignore_response()
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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
|
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()
|
set_modified_response_minus_block_text()
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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'unviewed' in res.data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,39 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from . util import live_server_setup
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_functionality(client, live_server, measure_memory_usage):
|
||||||
def test_trigger_functionality(client, live_server, measure_memory_usage):
|
|
||||||
|
|
||||||
live_server_setup(live_server)
|
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
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("imports.import_page"),
|
url_for("imports.import_page"),
|
||||||
data={"urls": "https://changedetection.io"},
|
data={"urls": test_url},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
assert b"1 Imported" in res.data
|
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(
|
res = client.get(
|
||||||
url_for("ui.form_clone", uuid="first"),
|
url_for("ui.form_clone", uuid="first"),
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
existing_uuids = set()
|
||||||
|
|
||||||
assert b"Cloned." in res.data
|
for uuid, watch in live_server.app.config['DATASTORE'].data['watching'].items():
|
||||||
|
new_uuids = set(watch.history.keys())
|
||||||
|
duplicates = existing_uuids.intersection(new_uuids)
|
||||||
|
assert len(duplicates) == 0
|
||||||
|
existing_uuids.update(new_uuids)
|
||||||
|
|
||||||
|
assert b"Cloned" in res.data
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
import urllib
|
import time
|
||||||
|
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import live_server_setup, wait_for_all_checks
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
@@ -113,8 +113,9 @@ def test_conditions_with_text_and_number(client, live_server):
|
|||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
# 75 is > 20 and < 100 and contains "5"
|
# 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
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ def test_conditions_with_text_and_number(client, live_server):
|
|||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# Should NOT be marked as having changes since not all conditions are met
|
# 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
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ def test_check_markup_include_filters_restriction(client, live_server, measure_m
|
|||||||
|
|
||||||
# It should have 'unviewed' still
|
# It should have 'unviewed' still
|
||||||
# Because it should be looking at only that 'sametext' id
|
# 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
|
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(
|
res = client.get(
|
||||||
url_for("index"),
|
url_for("watchlist.index"),
|
||||||
follow_redirects=True
|
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)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
res = client.get(
|
res = client.get(
|
||||||
url_for("index"),
|
url_for("watchlist.index"),
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
|
|||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# There should not be an unviewed change, as changes should be removed
|
# 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
|
assert b"unviewed" not in res.data
|
||||||
|
|
||||||
# Re #2752
|
# Re #2752
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _runner_test_http_errors(client, live_server, http_code, expected_text):
|
|||||||
# Give the thread time to pick it up
|
# Give the thread time to pick it up
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("watchlist.index"))
|
||||||
# no change
|
# no change
|
||||||
assert b'unviewed' not in res.data
|
assert b'unviewed' not in res.data
|
||||||
assert bytes(expected_text.encode('utf-8')) 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
|
# Give the thread time to pick it up
|
||||||
wait_for_all_checks(client)
|
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
|
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
|
assert found_name_resolution_error
|
||||||
# Should always record that we tried
|
# 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)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# We should see the DNS error
|
# 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
|
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
|
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
|
# Now the error should be gone
|
||||||
wait_for_all_checks(client)
|
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
|
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
|
assert not found_name_resolution_error
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage):
|
|||||||
assert b"Updated watch." in res.data
|
assert b"Updated watch." in res.data
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("watchlist.index"))
|
||||||
|
|
||||||
# Issue 1828
|
# Issue 1828
|
||||||
assert b'not at the start of the expression' not in res.data
|
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
|
# Give the thread time to pick it up
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("watchlist.index"))
|
||||||
#issue 1828
|
#issue 1828
|
||||||
assert b'not at the start of the expression' not in res.data
|
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
|
# It should have 'unviewed' still
|
||||||
# Because it should be looking at only that 'sametext' id
|
# 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
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
# Check HTML conversion detected and workd
|
# Check HTML conversion detected and workd
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ def run_filter_test(client, live_server, content_filter):
|
|||||||
checked += 1
|
checked += 1
|
||||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
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 b'Warning, no filters were found' in res.data
|
||||||
assert not os.path.isfile("test-datastore/notification.txt")
|
assert not os.path.isfile("test-datastore/notification.txt")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user