mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-12-03 22:55:33 +00:00
Compare commits
24 Commits
restock-pl
...
update-app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f210f5895 | ||
|
|
543cb205d2 | ||
|
|
273adfa0a4 | ||
|
|
8ecfd17973 | ||
|
|
f5b7043aae | ||
|
|
19f3851c9d | ||
|
|
7f2fa20318 | ||
|
|
3a0c992f1a | ||
|
|
e16814e40b | ||
|
|
e312661ff5 | ||
|
|
337fcab3f1 | ||
|
|
eaccd6026c | ||
|
|
5b70625eaa | ||
|
|
60d292107d | ||
|
|
1cb38347da | ||
|
|
55fe2abf42 | ||
|
|
4225900ec3 | ||
|
|
1fb4342488 | ||
|
|
7071df061a | ||
|
|
6dd1fa2b88 | ||
|
|
371f85d544 | ||
|
|
932cf15e1e | ||
|
|
bf0d410d32 | ||
|
|
730f37c7ba |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||||
|
|
||||||
__version__ = '0.46.02'
|
__version__ = '0.46.04'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
78
changedetectionio/apprise_plugin/__init__.py
Normal file
78
changedetectionio/apprise_plugin/__init__.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# include the decorator
|
||||||
|
from apprise.decorators import notify
|
||||||
|
|
||||||
|
@notify(on="delete")
|
||||||
|
@notify(on="deletes")
|
||||||
|
@notify(on="get")
|
||||||
|
@notify(on="gets")
|
||||||
|
@notify(on="post")
|
||||||
|
@notify(on="posts")
|
||||||
|
@notify(on="put")
|
||||||
|
@notify(on="puts")
|
||||||
|
def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from apprise.utils import parse_url as apprise_parse_url
|
||||||
|
from apprise import URLBase
|
||||||
|
|
||||||
|
url = kwargs['meta'].get('url')
|
||||||
|
|
||||||
|
if url.startswith('post'):
|
||||||
|
r = requests.post
|
||||||
|
elif url.startswith('get'):
|
||||||
|
r = requests.get
|
||||||
|
elif url.startswith('put'):
|
||||||
|
r = requests.put
|
||||||
|
elif url.startswith('delete'):
|
||||||
|
r = requests.delete
|
||||||
|
|
||||||
|
url = url.replace('post://', 'http://')
|
||||||
|
url = url.replace('posts://', 'https://')
|
||||||
|
url = url.replace('put://', 'http://')
|
||||||
|
url = url.replace('puts://', 'https://')
|
||||||
|
url = url.replace('get://', 'http://')
|
||||||
|
url = url.replace('gets://', 'https://')
|
||||||
|
url = url.replace('put://', 'http://')
|
||||||
|
url = url.replace('puts://', 'https://')
|
||||||
|
url = url.replace('delete://', 'http://')
|
||||||
|
url = url.replace('deletes://', 'https://')
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
params = {}
|
||||||
|
auth = None
|
||||||
|
|
||||||
|
# Convert /foobar?+some-header=hello to proper header dictionary
|
||||||
|
results = apprise_parse_url(url)
|
||||||
|
if results:
|
||||||
|
# Add our headers that the user can potentially over-ride if they wish
|
||||||
|
# to to our returned result set and tidy entries by unquoting them
|
||||||
|
headers = {URLBase.unquote(x): URLBase.unquote(y)
|
||||||
|
for x, y in results['qsd+'].items()}
|
||||||
|
|
||||||
|
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
|
||||||
|
# In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
|
||||||
|
# but here we are making straight requests, so we need todo convert this against apprise's logic
|
||||||
|
for k, v in results['qsd'].items():
|
||||||
|
if not k.strip('+-') in results['qsd+'].keys():
|
||||||
|
params[URLBase.unquote(k)] = URLBase.unquote(v)
|
||||||
|
|
||||||
|
# Determine Authentication
|
||||||
|
auth = ''
|
||||||
|
if results.get('user') and results.get('password'):
|
||||||
|
auth = (URLBase.unquote(results.get('user')), URLBase.unquote(results.get('user')))
|
||||||
|
elif results.get('user'):
|
||||||
|
auth = (URLBase.unquote(results.get('user')))
|
||||||
|
|
||||||
|
# Try to auto-guess if it's JSON
|
||||||
|
try:
|
||||||
|
json.loads(body)
|
||||||
|
headers['Content-Type'] = 'application/json; charset=utf-8'
|
||||||
|
except ValueError as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
r(results.get('url'),
|
||||||
|
auth=auth,
|
||||||
|
data=body.encode('utf-8') if type(body) is str else body,
|
||||||
|
headers=headers,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
@@ -85,7 +85,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui(
|
browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui(
|
||||||
playwright_browser=browsersteps_start_session['browser'],
|
playwright_browser=browsersteps_start_session['browser'],
|
||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
start_url=datastore.data['watching'][watch_uuid].get('url')
|
start_url=datastore.data['watching'][watch_uuid].get('url'),
|
||||||
|
headers=datastore.data['watching'][watch_uuid].get('headers')
|
||||||
)
|
)
|
||||||
|
|
||||||
# For test
|
# For test
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ xpath://body/div/span[contains(@class, 'example-class')]",
|
|||||||
{% if '/text()' in 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>
|
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br>
|
<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>
|
<ul id="advanced-help-selectors">
|
||||||
<li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
|
<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).
|
<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>
|
<ul>
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class Fetcher():
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
import importlib.resources
|
import importlib.resources
|
||||||
self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
|
self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
|
||||||
self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text()
|
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):
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
import chardet
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import requests
|
|
||||||
from changedetectionio import strtobool
|
from changedetectionio import strtobool
|
||||||
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
|
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
|
||||||
from changedetectionio.content_fetchers.base import Fetcher
|
from changedetectionio.content_fetchers.base import Fetcher
|
||||||
@@ -28,6 +26,9 @@ class fetcher(Fetcher):
|
|||||||
is_binary=False,
|
is_binary=False,
|
||||||
empty_pages_are_a_change=False):
|
empty_pages_are_a_change=False):
|
||||||
|
|
||||||
|
import chardet
|
||||||
|
import requests
|
||||||
|
|
||||||
if self.browser_steps_get_valid_steps():
|
if self.browser_steps_get_valid_steps():
|
||||||
raise BrowserStepsInUnsupportedFetcher(url=url)
|
raise BrowserStepsInUnsupportedFetcher(url=url)
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function isItemInStock() {
|
|||||||
'vergriffen',
|
'vergriffen',
|
||||||
'vorbestellen',
|
'vorbestellen',
|
||||||
'vorbestellung ist bald möglich',
|
'vorbestellung ist bald möglich',
|
||||||
|
'we don\'t currently have any',
|
||||||
'we couldn\'t find any products that match',
|
'we couldn\'t find any products that match',
|
||||||
'we do not currently have an estimate of when this product will be back in stock.',
|
'we do not currently have an estimate of when this product will be back in stock.',
|
||||||
'we don\'t know when or if this item will be back in stock.',
|
'we don\'t know when or if this item will be back in stock.',
|
||||||
@@ -173,7 +174,8 @@ function isItemInStock() {
|
|||||||
const element = elementsToScan[i];
|
const element = elementsToScan[i];
|
||||||
// outside the 'fold' or some weird text in the heading area
|
// outside the 'fold' or some weird text in the heading area
|
||||||
// .getBoundingClientRect() was causing a crash in chrome 119, can only be run on contentVisibility != hidden
|
// .getBoundingClientRect() was causing a crash in chrome 119, can only be run on contentVisibility != hidden
|
||||||
if (element.getBoundingClientRect().top + window.scrollY >= vh + 150 || element.getBoundingClientRect().top + window.scrollY <= 100) {
|
// Note: theres also an automated test that places the 'out of stock' text fairly low down
|
||||||
|
if (element.getBoundingClientRect().top + window.scrollY >= vh + 250 || element.getBoundingClientRect().top + window.scrollY <= 100) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
elementText = "";
|
elementText = "";
|
||||||
@@ -187,7 +189,7 @@ function isItemInStock() {
|
|||||||
// and these mean its out of stock
|
// and these mean its out of stock
|
||||||
for (const outOfStockText of outOfStockTexts) {
|
for (const outOfStockText of outOfStockTexts) {
|
||||||
if (elementText.includes(outOfStockText)) {
|
if (elementText.includes(outOfStockText)) {
|
||||||
console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}"`)
|
console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}" - offset top ${element.getBoundingClientRect().top}, page height is ${vh}`)
|
||||||
return outOfStockText; // item is out of stock
|
return outOfStockText; // item is out of stock
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ visibleElementsArray.forEach(function (element) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let label = "not-interesting" // A placeholder, the actual labels for training are done by hand for now
|
||||||
|
|
||||||
|
let text = element.textContent.trim().slice(0, 30).trim();
|
||||||
|
while (/\n{2,}|\t{2,}/.test(text)) {
|
||||||
|
text = text.replace(/\n{2,}/g, '\n').replace(/\t{2,}/g, '\t')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to identify any possible currency amounts "Sale: 4000" or "Sale now 3000 Kc", can help with the training.
|
||||||
|
const hasDigitCurrency = (/\d/.test(text.slice(0, 6)) || /\d/.test(text.slice(-6)) ) && /([€£$¥₩₹]|USD|AUD|EUR|Kč|kr|SEK|,–)/.test(text) ;
|
||||||
|
|
||||||
size_pos.push({
|
size_pos.push({
|
||||||
xpath: xpath_result,
|
xpath: xpath_result,
|
||||||
@@ -171,9 +180,16 @@ visibleElementsArray.forEach(function (element) {
|
|||||||
height: Math.round(bbox['height']),
|
height: Math.round(bbox['height']),
|
||||||
left: Math.floor(bbox['left']),
|
left: Math.floor(bbox['left']),
|
||||||
top: Math.floor(bbox['top']) + scroll_y,
|
top: Math.floor(bbox['top']) + scroll_y,
|
||||||
|
// tagName used by Browser Steps
|
||||||
tagName: (element.tagName) ? element.tagName.toLowerCase() : '',
|
tagName: (element.tagName) ? element.tagName.toLowerCase() : '',
|
||||||
|
// tagtype used by Browser Steps
|
||||||
tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '',
|
tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '',
|
||||||
isClickable: window.getComputedStyle(element).cursor == "pointer"
|
isClickable: window.getComputedStyle(element).cursor === "pointer",
|
||||||
|
// Used by the keras trainer
|
||||||
|
fontSize: window.getComputedStyle(element).getPropertyValue('font-size'),
|
||||||
|
fontWeight: window.getComputedStyle(element).getPropertyValue('font-weight'),
|
||||||
|
hasDigitCurrency: hasDigitCurrency,
|
||||||
|
label: label,
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -537,7 +537,8 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
import random
|
import random
|
||||||
from .apprise_asset import asset
|
from .apprise_asset import asset
|
||||||
apobj = apprise.Apprise(asset=asset)
|
apobj = apprise.Apprise(asset=asset)
|
||||||
|
# so that the custom endpoints are registered
|
||||||
|
from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
|
||||||
is_global_settings_form = request.args.get('mode', '') == 'global-settings'
|
is_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'
|
||||||
|
|
||||||
@@ -1377,17 +1378,19 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
import brotli
|
import brotli
|
||||||
|
|
||||||
watch = datastore.data['watching'].get(uuid)
|
watch = datastore.data['watching'].get(uuid)
|
||||||
if watch and os.path.isdir(watch.watch_data_dir):
|
if watch and watch.history.keys() and os.path.isdir(watch.watch_data_dir):
|
||||||
latest_filename = list(watch.history.keys())[0]
|
latest_filename = list(watch.history.keys())[-1]
|
||||||
html_fname = os.path.join(watch.watch_data_dir, f"{latest_filename}.html.br")
|
html_fname = os.path.join(watch.watch_data_dir, f"{latest_filename}.html.br")
|
||||||
if html_fname.endswith('.br'):
|
with open(html_fname, 'rb') as f:
|
||||||
# Read and decompress the Brotli file
|
if html_fname.endswith('.br'):
|
||||||
with open(html_fname, 'rb') as f:
|
# Read and decompress the Brotli file
|
||||||
decompressed_data = brotli.decompress(f.read())
|
decompressed_data = brotli.decompress(f.read())
|
||||||
|
else:
|
||||||
|
decompressed_data = f.read()
|
||||||
|
|
||||||
buffer = BytesIO(decompressed_data)
|
buffer = BytesIO(decompressed_data)
|
||||||
|
|
||||||
return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html')
|
return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html')
|
||||||
|
|
||||||
|
|
||||||
# Return a 500 error
|
# Return a 500 error
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ class ValidateAppRiseServers(object):
|
|||||||
def __call__(self, form, field):
|
def __call__(self, form, field):
|
||||||
import apprise
|
import apprise
|
||||||
apobj = apprise.Apprise()
|
apobj = apprise.Apprise()
|
||||||
|
# so that the custom endpoints are registered
|
||||||
|
from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
|
||||||
for server_url in field.data:
|
for server_url in field.data:
|
||||||
if not apobj.add(server_url):
|
if not apobj.add(server_url):
|
||||||
message = field.gettext('\'%s\' is not a valid AppRise URL.' % (server_url))
|
message = field.gettext('\'%s\' is not a valid AppRise URL.' % (server_url))
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from inscriptis import get_text
|
|
||||||
from jsonpath_ng.ext import parse
|
|
||||||
from typing import List
|
from typing import List
|
||||||
from inscriptis.model.config import ParserConfig
|
|
||||||
from xml.sax.saxutils import escape as xml_escape
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -39,6 +33,7 @@ def perl_style_slash_enclosed_regex_to_options(regex):
|
|||||||
|
|
||||||
# Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches
|
# Given a CSS Rule, and a blob of HTML, return the blob of HTML that matches
|
||||||
def include_filters(include_filters, html_content, append_pretty_line_formatting=False):
|
def include_filters(include_filters, html_content, append_pretty_line_formatting=False):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
soup = BeautifulSoup(html_content, "html.parser")
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
html_block = ""
|
html_block = ""
|
||||||
r = soup.select(include_filters, separator="")
|
r = soup.select(include_filters, separator="")
|
||||||
@@ -56,6 +51,7 @@ def include_filters(include_filters, html_content, append_pretty_line_formatting
|
|||||||
return html_block
|
return html_block
|
||||||
|
|
||||||
def subtractive_css_selector(css_selector, html_content):
|
def subtractive_css_selector(css_selector, html_content):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
soup = BeautifulSoup(html_content, "html.parser")
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
for item in soup.select(css_selector):
|
for item in soup.select(css_selector):
|
||||||
item.decompose()
|
item.decompose()
|
||||||
@@ -181,6 +177,7 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals
|
|||||||
|
|
||||||
# Extract/find element
|
# Extract/find element
|
||||||
def extract_element(find='title', html_content=''):
|
def extract_element(find='title', html_content=''):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
#Re #106, be sure to handle when its not found
|
#Re #106, be sure to handle when its not found
|
||||||
element_text = None
|
element_text = None
|
||||||
@@ -194,6 +191,8 @@ def extract_element(find='title', html_content=''):
|
|||||||
|
|
||||||
#
|
#
|
||||||
def _parse_json(json_data, json_filter):
|
def _parse_json(json_data, json_filter):
|
||||||
|
from jsonpath_ng.ext import parse
|
||||||
|
|
||||||
if json_filter.startswith("json:"):
|
if json_filter.startswith("json:"):
|
||||||
jsonpath_expression = parse(json_filter.replace('json:', ''))
|
jsonpath_expression = parse(json_filter.replace('json:', ''))
|
||||||
match = jsonpath_expression.find(json_data)
|
match = jsonpath_expression.find(json_data)
|
||||||
@@ -242,6 +241,8 @@ def _get_stripped_text_from_json_match(match):
|
|||||||
# json_filter - ie json:$..price
|
# json_filter - ie json:$..price
|
||||||
# ensure_is_ldjson_info_type - str "product", optional, "@type == product" (I dont know how to do that as a json selector)
|
# ensure_is_ldjson_info_type - str "product", optional, "@type == product" (I dont know how to do that as a json selector)
|
||||||
def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None):
|
def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
stripped_text_from_html = False
|
stripped_text_from_html = False
|
||||||
# https://github.com/dgtlmoon/changedetection.io/pull/2041#issuecomment-1848397161w
|
# https://github.com/dgtlmoon/changedetection.io/pull/2041#issuecomment-1848397161w
|
||||||
# Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags
|
# Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags
|
||||||
@@ -352,6 +353,7 @@ def strip_ignore_text(content, wordlist, mode="content"):
|
|||||||
return "\n".encode('utf8').join(output)
|
return "\n".encode('utf8').join(output)
|
||||||
|
|
||||||
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
|
||||||
pattern = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\s*)*)\]\]>'
|
pattern = '<!\[CDATA\[(\s*(?:.(?<!\]\]>)\s*)*)\]\]>'
|
||||||
def repl(m):
|
def repl(m):
|
||||||
text = m.group(1)
|
text = m.group(1)
|
||||||
@@ -360,6 +362,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(html_content: str, render_anchor_tag_content=False, is_rss=False) -> str:
|
||||||
|
from inscriptis import get_text
|
||||||
|
from inscriptis.model.config import ParserConfig
|
||||||
|
|
||||||
"""Converts html string to a string with just the text. If ignoring
|
"""Converts html string to a string with just the text. If ignoring
|
||||||
rendering anchor tag content is enable, anchor tag content are also
|
rendering anchor tag content is enable, anchor tag content are also
|
||||||
included in the text
|
included in the text
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import apprise
|
|
||||||
import time
|
import time
|
||||||
from apprise import NotifyFormat
|
from apprise import NotifyFormat
|
||||||
import json
|
import apprise
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
valid_tokens = {
|
valid_tokens = {
|
||||||
'base_url': '',
|
'base_url': '',
|
||||||
'current_snapshot': '',
|
'current_snapshot': '',
|
||||||
@@ -34,86 +35,11 @@ valid_notification_formats = {
|
|||||||
default_notification_format_for_watch: default_notification_format_for_watch
|
default_notification_format_for_watch: default_notification_format_for_watch
|
||||||
}
|
}
|
||||||
|
|
||||||
# include the decorator
|
|
||||||
from apprise.decorators import notify
|
|
||||||
|
|
||||||
@notify(on="delete")
|
|
||||||
@notify(on="deletes")
|
|
||||||
@notify(on="get")
|
|
||||||
@notify(on="gets")
|
|
||||||
@notify(on="post")
|
|
||||||
@notify(on="posts")
|
|
||||||
@notify(on="put")
|
|
||||||
@notify(on="puts")
|
|
||||||
def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
|
|
||||||
import requests
|
|
||||||
from apprise.utils import parse_url as apprise_parse_url
|
|
||||||
from apprise import URLBase
|
|
||||||
|
|
||||||
url = kwargs['meta'].get('url')
|
|
||||||
|
|
||||||
if url.startswith('post'):
|
|
||||||
r = requests.post
|
|
||||||
elif url.startswith('get'):
|
|
||||||
r = requests.get
|
|
||||||
elif url.startswith('put'):
|
|
||||||
r = requests.put
|
|
||||||
elif url.startswith('delete'):
|
|
||||||
r = requests.delete
|
|
||||||
|
|
||||||
url = url.replace('post://', 'http://')
|
|
||||||
url = url.replace('posts://', 'https://')
|
|
||||||
url = url.replace('put://', 'http://')
|
|
||||||
url = url.replace('puts://', 'https://')
|
|
||||||
url = url.replace('get://', 'http://')
|
|
||||||
url = url.replace('gets://', 'https://')
|
|
||||||
url = url.replace('put://', 'http://')
|
|
||||||
url = url.replace('puts://', 'https://')
|
|
||||||
url = url.replace('delete://', 'http://')
|
|
||||||
url = url.replace('deletes://', 'https://')
|
|
||||||
|
|
||||||
headers = {}
|
|
||||||
params = {}
|
|
||||||
auth = None
|
|
||||||
|
|
||||||
# Convert /foobar?+some-header=hello to proper header dictionary
|
|
||||||
results = apprise_parse_url(url)
|
|
||||||
if results:
|
|
||||||
# Add our headers that the user can potentially over-ride if they wish
|
|
||||||
# to to our returned result set and tidy entries by unquoting them
|
|
||||||
headers = {URLBase.unquote(x): URLBase.unquote(y)
|
|
||||||
for x, y in results['qsd+'].items()}
|
|
||||||
|
|
||||||
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
|
|
||||||
# In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
|
|
||||||
# but here we are making straight requests, so we need todo convert this against apprise's logic
|
|
||||||
for k, v in results['qsd'].items():
|
|
||||||
if not k.strip('+-') in results['qsd+'].keys():
|
|
||||||
params[URLBase.unquote(k)] = URLBase.unquote(v)
|
|
||||||
|
|
||||||
# Determine Authentication
|
|
||||||
auth = ''
|
|
||||||
if results.get('user') and results.get('password'):
|
|
||||||
auth = (URLBase.unquote(results.get('user')), URLBase.unquote(results.get('user')))
|
|
||||||
elif results.get('user'):
|
|
||||||
auth = (URLBase.unquote(results.get('user')))
|
|
||||||
|
|
||||||
# Try to auto-guess if it's JSON
|
|
||||||
try:
|
|
||||||
json.loads(body)
|
|
||||||
headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
||||||
except ValueError as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
r(results.get('url'),
|
|
||||||
auth=auth,
|
|
||||||
data=body.encode('utf-8') if type(body) is str else body,
|
|
||||||
headers=headers,
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def process_notification(n_object, datastore):
|
def process_notification(n_object, datastore):
|
||||||
|
# so that the custom endpoints are registered
|
||||||
|
from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
|
||||||
|
|
||||||
from .safe_jinja import render as jinja_render
|
from .safe_jinja import render as jinja_render
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
|
||||||
from changedetectionio.model.Watch import model as BaseWatch
|
|
||||||
import re
|
|
||||||
from babel.numbers import parse_decimal
|
from babel.numbers import parse_decimal
|
||||||
|
from changedetectionio.model.Watch import model as BaseWatch
|
||||||
|
from typing import Union
|
||||||
|
import re
|
||||||
|
|
||||||
class Restock(dict):
|
class Restock(dict):
|
||||||
|
|
||||||
def parse_currency(self, raw_value: str) -> float:
|
def parse_currency(self, raw_value: str) -> Union[float, None]:
|
||||||
# Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer.
|
# Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer.
|
||||||
standardized_value = raw_value
|
standardized_value = raw_value
|
||||||
|
|
||||||
@@ -21,8 +22,11 @@ class Restock(dict):
|
|||||||
# Remove any non-numeric characters except for the decimal point
|
# Remove any non-numeric characters except for the decimal point
|
||||||
standardized_value = re.sub(r'[^\d.-]', '', standardized_value)
|
standardized_value = re.sub(r'[^\d.-]', '', standardized_value)
|
||||||
|
|
||||||
# Convert to float
|
if standardized_value:
|
||||||
return float(parse_decimal(standardized_value, locale='en'))
|
# Convert to float
|
||||||
|
return float(parse_decimal(standardized_value, locale='en'))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Define default values
|
# Define default values
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ from .. import difference_detection_processor
|
|||||||
from ..exceptions import ProcessorException
|
from ..exceptions import ProcessorException
|
||||||
from . import Restock
|
from . import Restock
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
import urllib3
|
import urllib3
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -27,6 +26,25 @@ def _search_prop_by_value(matches, value):
|
|||||||
if value in prop[0]:
|
if value in prop[0]:
|
||||||
return prop[1] # Yield the desired value and exit the function
|
return prop[1] # Yield the desired value and exit the function
|
||||||
|
|
||||||
|
def _deduplicate_prices(data):
|
||||||
|
seen = set()
|
||||||
|
unique_data = []
|
||||||
|
|
||||||
|
for datum in data:
|
||||||
|
# Convert 'value' to float if it can be a numeric string, otherwise leave it as is
|
||||||
|
try:
|
||||||
|
normalized_value = float(datum.value) if isinstance(datum.value, str) and datum.value.replace('.', '', 1).isdigit() else datum.value
|
||||||
|
except ValueError:
|
||||||
|
normalized_value = datum.value
|
||||||
|
|
||||||
|
# If the normalized value hasn't been seen yet, add it to unique data
|
||||||
|
if normalized_value not in seen:
|
||||||
|
unique_data.append(datum)
|
||||||
|
seen.add(normalized_value)
|
||||||
|
|
||||||
|
return unique_data
|
||||||
|
|
||||||
|
|
||||||
# should return Restock()
|
# should return Restock()
|
||||||
# add casting?
|
# add casting?
|
||||||
def get_itemprop_availability(html_content) -> Restock:
|
def get_itemprop_availability(html_content) -> Restock:
|
||||||
@@ -36,17 +54,21 @@ def get_itemprop_availability(html_content) -> Restock:
|
|||||||
"""
|
"""
|
||||||
from jsonpath_ng import parse
|
from jsonpath_ng import parse
|
||||||
|
|
||||||
|
import re
|
||||||
now = time.time()
|
now = time.time()
|
||||||
import extruct
|
import extruct
|
||||||
logger.trace(f"Imported extruct module in {time.time() - now:.3f}s")
|
logger.trace(f"Imported extruct module in {time.time() - now:.3f}s")
|
||||||
|
|
||||||
value = {}
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Extruct is very slow, I'm wondering if some ML is going to be faster (800ms on my i7), 'rdfa' seems to be the heaviest.
|
# Extruct is very slow, I'm wondering if some ML is going to be faster (800ms on my i7), 'rdfa' seems to be the heaviest.
|
||||||
|
|
||||||
syntaxes = ['dublincore', 'json-ld', 'microdata', 'microformat', 'opengraph']
|
syntaxes = ['dublincore', 'json-ld', 'microdata', 'microformat', 'opengraph']
|
||||||
|
try:
|
||||||
|
data = extruct.extract(html_content, syntaxes=syntaxes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unable to extract data, document parsing with extruct failed with {type(e).__name__} - {str(e)}")
|
||||||
|
return Restock()
|
||||||
|
|
||||||
data = extruct.extract(html_content, syntaxes=syntaxes)
|
|
||||||
logger.trace(f"Extruct basic extract of all metadata done in {time.time() - now:.3f}s")
|
logger.trace(f"Extruct basic extract of all metadata done in {time.time() - now:.3f}s")
|
||||||
|
|
||||||
# First phase, dead simple scanning of anything that looks useful
|
# First phase, dead simple scanning of anything that looks useful
|
||||||
@@ -57,7 +79,7 @@ def get_itemprop_availability(html_content) -> Restock:
|
|||||||
pricecurrency_parse = parse('$..(pricecurrency|currency|priceCurrency )')
|
pricecurrency_parse = parse('$..(pricecurrency|currency|priceCurrency )')
|
||||||
availability_parse = parse('$..(availability|Availability)')
|
availability_parse = parse('$..(availability|Availability)')
|
||||||
|
|
||||||
price_result = price_parse.find(data)
|
price_result = _deduplicate_prices(price_parse.find(data))
|
||||||
if price_result:
|
if price_result:
|
||||||
# Right now, we just support single product items, maybe we will store the whole actual metadata seperately in teh future and
|
# Right now, we just support single product items, maybe we will store the whole actual metadata seperately in teh future and
|
||||||
# parse that for the UI?
|
# parse that for the UI?
|
||||||
@@ -119,6 +141,10 @@ class perform_site_check(difference_detection_processor):
|
|||||||
xpath_data = None
|
xpath_data = None
|
||||||
|
|
||||||
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from functools import partial
|
||||||
if not watch:
|
if not watch:
|
||||||
raise Exception("Watch no longer exists.")
|
raise Exception("Watch no longer exists.")
|
||||||
|
|
||||||
@@ -146,7 +172,11 @@ class perform_site_check(difference_detection_processor):
|
|||||||
|
|
||||||
itemprop_availability = {}
|
itemprop_availability = {}
|
||||||
try:
|
try:
|
||||||
itemprop_availability = get_itemprop_availability(html_content=self.fetcher.content)
|
with ProcessPoolExecutor() as executor:
|
||||||
|
# Use functools.partial to create a callable with arguments
|
||||||
|
# anything using bs4/lxml etc is quite "leaky"
|
||||||
|
future = executor.submit(partial(get_itemprop_availability, self.fetcher.content))
|
||||||
|
itemprop_availability = future.result()
|
||||||
except MoreThanOnePriceFound as e:
|
except MoreThanOnePriceFound as e:
|
||||||
# Add the real data
|
# Add the real data
|
||||||
raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
|
raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.",
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class PDFToHTMLToolNotFound(ValueError):
|
|||||||
class perform_site_check(difference_detection_processor):
|
class perform_site_check(difference_detection_processor):
|
||||||
|
|
||||||
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
changed_detected = False
|
changed_detected = False
|
||||||
html_content = ""
|
html_content = ""
|
||||||
screenshot = False # as bytes
|
screenshot = False # as bytes
|
||||||
@@ -171,20 +174,30 @@ class perform_site_check(difference_detection_processor):
|
|||||||
for filter_rule in include_filters_rule:
|
for filter_rule in include_filters_rule:
|
||||||
# For HTML/XML we offer xpath as an option, just start a regular xPath "/.."
|
# For HTML/XML we offer xpath as an option, just start a regular xPath "/.."
|
||||||
if filter_rule[0] == '/' or filter_rule.startswith('xpath:'):
|
if filter_rule[0] == '/' or filter_rule.startswith('xpath:'):
|
||||||
html_content += html_tools.xpath_filter(xpath_filter=filter_rule.replace('xpath:', ''),
|
with ProcessPoolExecutor() as executor:
|
||||||
|
# Use functools.partial to create a callable with arguments - anything using bs4/lxml etc is quite "leaky"
|
||||||
|
future = executor.submit(partial(html_tools.xpath_filter, xpath_filter=filter_rule.replace('xpath:', ''),
|
||||||
html_content=self.fetcher.content,
|
html_content=self.fetcher.content,
|
||||||
append_pretty_line_formatting=not watch.is_source_type_url,
|
append_pretty_line_formatting=not watch.is_source_type_url,
|
||||||
is_rss=is_rss)
|
is_rss=is_rss))
|
||||||
|
html_content += future.result()
|
||||||
|
|
||||||
elif filter_rule.startswith('xpath1:'):
|
elif filter_rule.startswith('xpath1:'):
|
||||||
html_content += html_tools.xpath1_filter(xpath_filter=filter_rule.replace('xpath1:', ''),
|
with ProcessPoolExecutor() as executor:
|
||||||
|
# Use functools.partial to create a callable with arguments - anything using bs4/lxml etc is quite "leaky"
|
||||||
|
future = executor.submit(partial(html_tools.xpath1_filter, xpath_filter=filter_rule.replace('xpath1:', ''),
|
||||||
html_content=self.fetcher.content,
|
html_content=self.fetcher.content,
|
||||||
append_pretty_line_formatting=not watch.is_source_type_url,
|
append_pretty_line_formatting=not watch.is_source_type_url,
|
||||||
is_rss=is_rss)
|
is_rss=is_rss))
|
||||||
|
html_content += future.result()
|
||||||
else:
|
else:
|
||||||
# CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text
|
with ProcessPoolExecutor() as executor:
|
||||||
html_content += html_tools.include_filters(include_filters=filter_rule,
|
# Use functools.partial to create a callable with arguments - anything using bs4/lxml etc is quite "leaky"
|
||||||
|
# CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text
|
||||||
|
future = executor.submit(partial(html_tools.include_filters, include_filters=filter_rule,
|
||||||
html_content=self.fetcher.content,
|
html_content=self.fetcher.content,
|
||||||
append_pretty_line_formatting=not watch.is_source_type_url)
|
append_pretty_line_formatting=not watch.is_source_type_url))
|
||||||
|
html_content += future.result()
|
||||||
|
|
||||||
if not html_content.strip():
|
if not html_content.strip():
|
||||||
raise FilterNotFoundInResponse(msg=include_filters_rule, screenshot=self.fetcher.screenshot, xpath_data=self.fetcher.xpath_data)
|
raise FilterNotFoundInResponse(msg=include_filters_rule, screenshot=self.fetcher.screenshot, xpath_data=self.fetcher.xpath_data)
|
||||||
@@ -197,12 +210,13 @@ class perform_site_check(difference_detection_processor):
|
|||||||
else:
|
else:
|
||||||
# extract text
|
# extract text
|
||||||
do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False)
|
do_anchor = self.datastore.data["settings"]["application"].get("render_anchor_tag_content", False)
|
||||||
stripped_text_from_html = \
|
with ProcessPoolExecutor() as executor:
|
||||||
html_tools.html_to_text(
|
# Use functools.partial to create a callable with arguments - anything using bs4/lxml etc is quite "leaky"
|
||||||
html_content=html_content,
|
# CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text
|
||||||
|
future = executor.submit(partial(html_tools.html_to_text, html_content=html_content,
|
||||||
render_anchor_tag_content=do_anchor,
|
render_anchor_tag_content=do_anchor,
|
||||||
is_rss=is_rss # #1874 activate the <title workaround hack
|
is_rss=is_rss)) #1874 activate the <title workaround hack
|
||||||
)
|
stripped_text_from_html = future.result()
|
||||||
|
|
||||||
if watch.get('sort_text_alphabetically') and stripped_text_from_html:
|
if watch.get('sort_text_alphabetically') and stripped_text_from_html:
|
||||||
# Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap
|
# Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ $(document).ready(function () {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#notification-token-toggle").click(function (e) {
|
$(".toggle-show").click(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$('#notification-tokens-info').toggle();
|
let target = $(this).data('target');
|
||||||
|
$(target).toggle();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from threading import Lock
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import requests
|
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -270,6 +269,7 @@ class ChangeDetectionStore:
|
|||||||
self.needs_write_urgent = True
|
self.needs_write_urgent = True
|
||||||
|
|
||||||
def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True):
|
def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True):
|
||||||
|
import requests
|
||||||
|
|
||||||
if extras is None:
|
if extras is None:
|
||||||
extras = {}
|
extras = {}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
class="notification-urls" )
|
class="notification-urls" )
|
||||||
}}
|
}}
|
||||||
<div class="pure-form-message-inline">
|
<div class="pure-form-message-inline">
|
||||||
<ul>
|
<p>
|
||||||
<li>Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.</li>
|
<strong>Tip:</strong> Use <a target=_new href="https://github.com/caronc/apprise">AppRise Notification URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.<br>
|
||||||
|
</p>
|
||||||
|
<div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div>
|
||||||
|
<ul style="display: none" id="advanced-help-notifications">
|
||||||
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li>
|
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li>
|
||||||
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't send messages to other bots, so you should specify chat ID of non-bot user.</li>
|
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't send messages to other bots, so you should specify chat ID of non-bot user.</li>
|
||||||
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li>
|
<li><code><a target=_new href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> only supports very limited HTML and can fail when extra tags are sent, <a href="https://core.telegram.org/bots/api#html-style">read more here</a> (or use plaintext/markdown format)</li>
|
||||||
@@ -40,7 +43,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-controls">
|
<div class="pure-controls">
|
||||||
<div id="notification-token-toggle" class="pure-button button-tag button-xsmall">Show token/placeholders</div>
|
<div data-target="#notification-tokens-info" class="toggle-show pure-button button-tag button-xsmall">Show token/placeholders</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-controls" style="display: none;" id="notification-tokens-info">
|
<div class="pure-controls" style="display: none;" id="notification-tokens-info">
|
||||||
<table class="pure-table" id="token-table">
|
<table class="pure-table" id="token-table">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% 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>
|
||||||
|
<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script>
|
||||||
<script>
|
<script>
|
||||||
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
|
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
|
||||||
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
|
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
|
||||||
@@ -275,9 +276,9 @@ xpath://body/div/span[contains(@class, 'example-class')]",
|
|||||||
{% if '/text()' in 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>
|
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the <element> contains <![CDATA[]]></strong></span><br>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br>
|
<span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
|
||||||
|
<p><div data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div><br></p>
|
||||||
<ul>
|
<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>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).
|
<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>
|
<ul>
|
||||||
@@ -297,9 +298,12 @@ xpath://body/div/span[contains(@class, 'example-class')]",
|
|||||||
<li>To use XPath1.0: Prefix with <code>xpath1:</code></li>
|
<li>To use XPath1.0: Prefix with <code>xpath1:</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
<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
|
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>
|
href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="pure-control-group">
|
<fieldset class="pure-control-group">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
|
from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client, wait_for_notification_endpoint_output
|
||||||
from changedetectionio.notification import (
|
from changedetectionio.notification import (
|
||||||
default_notification_body,
|
default_notification_body,
|
||||||
default_notification_format,
|
default_notification_format,
|
||||||
@@ -94,7 +94,7 @@ def test_restock_detection(client, live_server, measure_memory_usage):
|
|||||||
assert b'not-in-stock' not in res.data
|
assert b'not-in-stock' not in res.data
|
||||||
|
|
||||||
# We should have a notification
|
# We should have a notification
|
||||||
time.sleep(2)
|
wait_for_notification_endpoint_output()
|
||||||
assert os.path.isfile("test-datastore/notification.txt"), "Notification received"
|
assert os.path.isfile("test-datastore/notification.txt"), "Notification received"
|
||||||
os.unlink("test-datastore/notification.txt")
|
os.unlink("test-datastore/notification.txt")
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ def test_restock_detection(client, live_server, measure_memory_usage):
|
|||||||
set_original_response()
|
set_original_response()
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
time.sleep(5)
|
||||||
assert not os.path.isfile("test-datastore/notification.txt"), "No notification should have fired when it went OUT OF STOCK by default"
|
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"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import os.path
|
import os.path
|
||||||
import time
|
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, wait_for_notification_endpoint_output
|
||||||
from changedetectionio import html_tools
|
from changedetectionio import html_tools
|
||||||
|
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
|
|||||||
assert b'unviewed' in res.data
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
# Takes a moment for apprise to fire
|
# Takes a moment for apprise to fire
|
||||||
time.sleep(3)
|
wait_for_notification_endpoint_output()
|
||||||
assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file"
|
assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file"
|
||||||
with open("test-datastore/notification.txt", 'rb') as f:
|
with open("test-datastore/notification.txt", 'rb') as f:
|
||||||
response = f.read()
|
response = f.read()
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
|||||||
|
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
uuid = extract_UUID_from_client(client)
|
||||||
|
|
||||||
|
# Check the 'get latest snapshot works'
|
||||||
|
res = client.get(url_for("watch_get_latest_html", uuid=uuid))
|
||||||
|
assert b'which has this one new line' in res.data
|
||||||
|
|
||||||
# Now something should be ready, indicated by having a 'unviewed' class
|
# Now something should be ready, indicated by having a 'unviewed' class
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("index"))
|
||||||
assert b'unviewed' in res.data
|
assert b'unviewed' in res.data
|
||||||
@@ -86,7 +92,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
|||||||
assert expected_url.encode('utf-8') in res.data
|
assert expected_url.encode('utf-8') in res.data
|
||||||
|
|
||||||
# Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times
|
# Following the 'diff' link, it should no longer display as 'unviewed' even after we recheck it a few times
|
||||||
res = client.get(url_for("diff_history_page", uuid="first"))
|
res = client.get(url_for("diff_history_page", uuid=uuid))
|
||||||
assert b'selected=""' in res.data, "Confirm diff history page loaded"
|
assert b'selected=""' in res.data, "Confirm diff history page loaded"
|
||||||
|
|
||||||
# Check the [preview] pulls the right one
|
# Check the [preview] pulls the right one
|
||||||
@@ -143,18 +149,12 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
|||||||
assert b'unviewed' not in res.data
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
# #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again
|
# #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again
|
||||||
uuid = extract_UUID_from_client(client)
|
|
||||||
client.get(url_for("clear_watch_history", uuid=uuid))
|
client.get(url_for("clear_watch_history", uuid=uuid))
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("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("index"))
|
||||||
assert b'preview/' in res.data
|
assert b'preview/' in res.data
|
||||||
|
|
||||||
|
|
||||||
# Check the 'get latest snapshot works'
|
|
||||||
res = client.get(url_for("watch_get_latest_html", uuid=uuid))
|
|
||||||
assert b'<head><title>head title</title></head>' in res.data
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Cleanup everything
|
# Cleanup everything
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import set_original_response, live_server_setup
|
from .util import set_original_response, live_server_setup, wait_for_notification_endpoint_output
|
||||||
from changedetectionio.model import App
|
from changedetectionio.model import App
|
||||||
|
|
||||||
|
|
||||||
@@ -102,14 +102,15 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
|
|||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
assert b"Updated watch." in res.data
|
assert b"Updated watch." in res.data
|
||||||
time.sleep(3)
|
wait_for_notification_endpoint_output()
|
||||||
|
|
||||||
# Shouldn't exist, shouldn't have fired
|
# Shouldn't exist, shouldn't have fired
|
||||||
assert not os.path.isfile("test-datastore/notification.txt")
|
assert not os.path.isfile("test-datastore/notification.txt")
|
||||||
# Now the filter should exist
|
# Now the filter should exist
|
||||||
set_response_with_filter()
|
set_response_with_filter()
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
time.sleep(3)
|
|
||||||
|
wait_for_notification_endpoint_output()
|
||||||
|
|
||||||
assert os.path.isfile("test-datastore/notification.txt")
|
assert os.path.isfile("test-datastore/notification.txt")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from loguru import logger
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks
|
from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks, \
|
||||||
|
wait_for_notification_endpoint_output
|
||||||
from changedetectionio.model import App
|
from changedetectionio.model import App
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +28,12 @@ def run_filter_test(client, live_server, content_filter):
|
|||||||
# Response WITHOUT the filter ID element
|
# Response WITHOUT the filter ID element
|
||||||
set_original_response()
|
set_original_response()
|
||||||
|
|
||||||
|
# Goto the edit page, add our ignore text
|
||||||
|
notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')
|
||||||
|
|
||||||
|
# Add our URL to the import page
|
||||||
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
|
|
||||||
# cleanup for the next
|
# cleanup for the next
|
||||||
client.get(
|
client.get(
|
||||||
url_for("form_delete", uuid="all"),
|
url_for("form_delete", uuid="all"),
|
||||||
@@ -34,83 +42,90 @@ def run_filter_test(client, live_server, content_filter):
|
|||||||
if os.path.isfile("test-datastore/notification.txt"):
|
if os.path.isfile("test-datastore/notification.txt"):
|
||||||
os.unlink("test-datastore/notification.txt")
|
os.unlink("test-datastore/notification.txt")
|
||||||
|
|
||||||
# Add our URL to the import page
|
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("form_quick_watch_add"),
|
url_for("import_page"),
|
||||||
data={"url": test_url, "tags": ''},
|
data={"urls": test_url},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
assert b"Watch added" in res.data
|
assert b"1 Imported" in res.data
|
||||||
|
|
||||||
# Give the thread time to pick up the first version
|
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
# Goto the edit page, add our ignore text
|
uuid = extract_UUID_from_client(client)
|
||||||
# Add our URL to the import page
|
|
||||||
url = url_for('test_notification_endpoint', _external=True)
|
|
||||||
notification_url = url.replace('http', 'json')
|
|
||||||
|
|
||||||
print(">>>> Notification URL: " + notification_url)
|
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure"
|
||||||
|
|
||||||
# Just a regular notification setting, this will be used by the special 'filter not found' notification
|
watch_data = {"notification_urls": notification_url,
|
||||||
notification_form_data = {"notification_urls": notification_url,
|
"notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
|
||||||
"notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
|
"notification_body": "BASE URL: {{base_url}}\n"
|
||||||
"notification_body": "BASE URL: {{base_url}}\n"
|
"Watch URL: {{watch_url}}\n"
|
||||||
"Watch URL: {{watch_url}}\n"
|
"Watch UUID: {{watch_uuid}}\n"
|
||||||
"Watch UUID: {{watch_uuid}}\n"
|
"Watch title: {{watch_title}}\n"
|
||||||
"Watch title: {{watch_title}}\n"
|
"Watch tag: {{watch_tag}}\n"
|
||||||
"Watch tag: {{watch_tag}}\n"
|
"Preview: {{preview_url}}\n"
|
||||||
"Preview: {{preview_url}}\n"
|
"Diff URL: {{diff_url}}\n"
|
||||||
"Diff URL: {{diff_url}}\n"
|
"Snapshot: {{current_snapshot}}\n"
|
||||||
"Snapshot: {{current_snapshot}}\n"
|
"Diff: {{diff}}\n"
|
||||||
"Diff: {{diff}}\n"
|
"Diff Full: {{diff_full}}\n"
|
||||||
"Diff Full: {{diff_full}}\n"
|
"Diff as Patch: {{diff_patch}}\n"
|
||||||
"Diff as Patch: {{diff_patch}}\n"
|
":-)",
|
||||||
":-)",
|
"notification_format": "Text",
|
||||||
"notification_format": "Text"}
|
"fetch_backend": "html_requests",
|
||||||
|
"filter_failure_notification_send": 'y',
|
||||||
|
"headers": "",
|
||||||
|
"tags": "my tag",
|
||||||
|
"title": "my title 123",
|
||||||
|
"time_between_check-hours": 5, # So that the queue runner doesnt also put it in
|
||||||
|
"url": test_url,
|
||||||
|
}
|
||||||
|
|
||||||
notification_form_data.update({
|
|
||||||
"url": test_url,
|
|
||||||
"tags": "my tag",
|
|
||||||
"title": "my title 123",
|
|
||||||
"headers": "",
|
|
||||||
"filter_failure_notification_send": 'y',
|
|
||||||
"include_filters": content_filter,
|
|
||||||
"fetch_backend": "html_requests"})
|
|
||||||
|
|
||||||
# A POST here will also reset the filter failure counter (filter_failure_notification_threshold_attempts)
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("edit_page", uuid="first"),
|
url_for("edit_page", uuid=uuid),
|
||||||
data=notification_form_data,
|
data=watch_data,
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
assert b"Updated watch." in res.data
|
assert b"Updated watch." in res.data
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure"
|
||||||
|
|
||||||
# Now the notification should not exist, because we didnt reach the threshold
|
# Now add a filter, because recheck hours == 5, ONLY pressing of the [edit] or [recheck all] should trigger
|
||||||
|
watch_data['include_filters'] = content_filter
|
||||||
|
res = client.post(
|
||||||
|
url_for("edit_page", uuid=uuid),
|
||||||
|
data=watch_data,
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
assert b"Updated watch." in res.data
|
||||||
|
|
||||||
|
# It should have checked once so far and given this error (because we hit SAVE)
|
||||||
|
|
||||||
|
wait_for_all_checks(client)
|
||||||
assert not os.path.isfile("test-datastore/notification.txt")
|
assert not os.path.isfile("test-datastore/notification.txt")
|
||||||
|
|
||||||
|
# Hitting [save] would have triggered a recheck, and we have a filter, so this would be ONE failure
|
||||||
|
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 1, "Should have been checked once"
|
||||||
|
|
||||||
# recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented)
|
# recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented)
|
||||||
for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT-2):
|
# Add 4 more checks
|
||||||
|
checked = 0
|
||||||
|
ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)
|
||||||
|
for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2):
|
||||||
|
checked += 1
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
time.sleep(2) # delay for apprise to fire
|
res = client.get(url_for("index"))
|
||||||
assert not os.path.isfile("test-datastore/notification.txt"), f"test-datastore/notification.txt should not exist - Attempt {i} when threshold is {App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT}"
|
assert b'Warning, no filters were found' in res.data
|
||||||
|
assert not os.path.isfile("test-datastore/notification.txt")
|
||||||
|
|
||||||
# We should see something in the frontend
|
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 5
|
||||||
res = client.get(url_for("index"))
|
|
||||||
assert b'Warning, no filters were found' in res.data
|
|
||||||
|
|
||||||
# One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold
|
# One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
time.sleep(2) # delay for apprise to fire
|
wait_for_notification_endpoint_output()
|
||||||
|
|
||||||
# Now it should exist and contain our "filter not found" alert
|
# Now it should exist and contain our "filter not found" alert
|
||||||
assert os.path.isfile("test-datastore/notification.txt")
|
assert os.path.isfile("test-datastore/notification.txt")
|
||||||
|
|
||||||
with open("test-datastore/notification.txt", 'r') as f:
|
with open("test-datastore/notification.txt", 'r') as f:
|
||||||
notification = f.read()
|
notification = f.read()
|
||||||
|
|
||||||
@@ -123,10 +138,11 @@ def run_filter_test(client, live_server, content_filter):
|
|||||||
set_response_with_filter()
|
set_response_with_filter()
|
||||||
|
|
||||||
# Try several times, it should NOT have 'filter not found'
|
# Try several times, it should NOT have 'filter not found'
|
||||||
for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT):
|
for i in range(0, ATTEMPT_THRESHOLD_SETTING + 2):
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
wait_for_notification_endpoint_output()
|
||||||
# It should have sent a notification, but..
|
# It should have sent a notification, but..
|
||||||
assert os.path.isfile("test-datastore/notification.txt")
|
assert os.path.isfile("test-datastore/notification.txt")
|
||||||
# but it should not contain the info about a failed filter (because there was none in this case)
|
# but it should not contain the info about a failed filter (because there was none in this case)
|
||||||
@@ -135,9 +151,6 @@ def run_filter_test(client, live_server, content_filter):
|
|||||||
assert not 'CSS/xPath filter was not present in the page' in notification
|
assert not 'CSS/xPath filter was not present in the page' in notification
|
||||||
|
|
||||||
# Re #1247 - All tokens got replaced correctly in the notification
|
# Re #1247 - All tokens got replaced correctly in the notification
|
||||||
res = client.get(url_for("index"))
|
|
||||||
uuid = extract_UUID_from_client(client)
|
|
||||||
# UUID is correct, but notification contains tag uuid as UUIID wtf
|
|
||||||
assert uuid in notification
|
assert uuid in notification
|
||||||
|
|
||||||
# cleanup for the next
|
# cleanup for the next
|
||||||
@@ -152,9 +165,11 @@ def test_setup(live_server):
|
|||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
|
|
||||||
def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage):
|
def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage):
|
||||||
|
# live_server_setup(live_server)
|
||||||
run_filter_test(client, live_server,'#nope-doesnt-exist')
|
run_filter_test(client, live_server,'#nope-doesnt-exist')
|
||||||
|
|
||||||
def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage):
|
def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage):
|
||||||
|
# live_server_setup(live_server)
|
||||||
run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]')
|
run_filter_test(client, live_server, '//*[@id="nope-doesnt-exist"]')
|
||||||
|
|
||||||
# Test that notification is never sent
|
# Test that notification is never sent
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
|
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
def set_nonrenderable_response():
|
def set_nonrenderable_response():
|
||||||
test_return_data = """<html>
|
test_return_data = """<html>
|
||||||
@@ -11,17 +13,16 @@ def set_nonrenderable_response():
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with open("test-datastore/endpoint-content.txt", "w") as f:
|
with open("test-datastore/endpoint-content.txt", "w") as f:
|
||||||
f.write(test_return_data)
|
f.write(test_return_data)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_zero_byte_response():
|
def set_zero_byte_response():
|
||||||
|
|
||||||
with open("test-datastore/endpoint-content.txt", "w") as f:
|
with open("test-datastore/endpoint-content.txt", "w") as f:
|
||||||
f.write("")
|
f.write("")
|
||||||
|
time.sleep(1)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage):
|
def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
|
from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client, wait_for_notification_endpoint_output
|
||||||
from ..notification import default_notification_format
|
from ..notification import default_notification_format
|
||||||
|
|
||||||
instock_props = [
|
instock_props = [
|
||||||
@@ -146,14 +146,13 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
|
|||||||
data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'},
|
data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# A change in price, should trigger a change by default
|
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"tags": "",
|
"tags": "",
|
||||||
"url": test_url,
|
"url": test_url,
|
||||||
"headers": "",
|
"headers": "",
|
||||||
|
"time_between_check-hours": 5,
|
||||||
'fetch_backend': "html_requests"
|
'fetch_backend': "html_requests"
|
||||||
}
|
}
|
||||||
data.update(extra_watch_edit_form)
|
data.update(extra_watch_edit_form)
|
||||||
@@ -178,11 +177,9 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
|
|||||||
assert b'1,000.45' or b'1000.45' in res.data #depending on locale
|
assert b'1,000.45' or b'1000.45' in res.data #depending on locale
|
||||||
assert b'unviewed' not in res.data
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
|
|
||||||
# price changed to something LESS than min (900), SHOULD be a change
|
# price changed to something LESS than min (900), SHOULD be a change
|
||||||
set_original_response(props_markup=instock_props[0], price='890.45')
|
set_original_response(props_markup=instock_props[0], price='890.45')
|
||||||
# let previous runs wait
|
|
||||||
time.sleep(1)
|
|
||||||
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
assert b'1 watches queued for rechecking.' in res.data
|
assert b'1 watches queued for rechecking.' in res.data
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
@@ -197,7 +194,8 @@ def _run_test_minmax_limit(client, extra_watch_edit_form):
|
|||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("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("index"))
|
||||||
assert b'1,890.45' or b'1890.45' in res.data
|
# Depending on the LOCALE it may be either of these (generally for US/default/etc)
|
||||||
|
assert b'1,890.45' in res.data or b'1890.45' in res.data
|
||||||
assert b'unviewed' in res.data
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
@@ -362,7 +360,7 @@ def test_change_with_notification_values(client, live_server):
|
|||||||
set_original_response(props_markup=instock_props[0], price='1950.45')
|
set_original_response(props_markup=instock_props[0], price='1950.45')
|
||||||
client.get(url_for("form_watch_checknow"))
|
client.get(url_for("form_watch_checknow"))
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
time.sleep(3)
|
wait_for_notification_endpoint_output()
|
||||||
assert os.path.isfile("test-datastore/notification.txt"), "Notification received"
|
assert os.path.isfile("test-datastore/notification.txt"), "Notification received"
|
||||||
with open("test-datastore/notification.txt", 'r') as f:
|
with open("test-datastore/notification.txt", 'r') as f:
|
||||||
notification = f.read()
|
notification = f.read()
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ def set_more_modified_response():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_notification_endpoint_output():
|
||||||
|
'''Apprise can take a few seconds to fire'''
|
||||||
|
from os.path import isfile
|
||||||
|
for i in range(1, 20):
|
||||||
|
time.sleep(1)
|
||||||
|
if isfile("test-datastore/notification.txt"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# kinda funky, but works for now
|
# kinda funky, but works for now
|
||||||
def extract_api_key_from_UI(client):
|
def extract_api_key_from_UI(client):
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ class update_worker(threading.Thread):
|
|||||||
'screenshot': None
|
'screenshot': None
|
||||||
})
|
})
|
||||||
self.notification_q.put(n_object)
|
self.notification_q.put(n_object)
|
||||||
logger.error(f"Sent filter not found notification for {watch_uuid}")
|
logger.debug(f"Sent filter not found notification for {watch_uuid}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"NOT sending filter not found notification for {watch_uuid} - no notification URLs")
|
||||||
|
|
||||||
def send_step_failure_notification(self, watch_uuid, step_n):
|
def send_step_failure_notification(self, watch_uuid, step_n):
|
||||||
watch = self.datastore.data['watching'].get(watch_uuid, False)
|
watch = self.datastore.data['watching'].get(watch_uuid, False)
|
||||||
@@ -364,18 +366,22 @@ class update_worker(threading.Thread):
|
|||||||
|
|
||||||
# Only when enabled, send the notification
|
# Only when enabled, send the notification
|
||||||
if watch.get('filter_failure_notification_send', False):
|
if watch.get('filter_failure_notification_send', False):
|
||||||
c = watch.get('consecutive_filter_failures', 5)
|
c = watch.get('consecutive_filter_failures', 0)
|
||||||
c += 1
|
c += 1
|
||||||
# Send notification if we reached the threshold?
|
# Send notification if we reached the threshold?
|
||||||
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts',
|
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)
|
||||||
0)
|
logger.debug(f"Filter for {uuid} not found, consecutive_filter_failures: {c} of threshold {threshold}")
|
||||||
logger.warning(f"Filter for {uuid} not found, consecutive_filter_failures: {c}")
|
if c >= threshold:
|
||||||
if threshold > 0 and c >= threshold:
|
|
||||||
if not watch.get('notification_muted'):
|
if not watch.get('notification_muted'):
|
||||||
|
logger.debug(f"Sending filter failed notification for {uuid}")
|
||||||
self.send_filter_failure_notification(uuid)
|
self.send_filter_failure_notification(uuid)
|
||||||
c = 0
|
c = 0
|
||||||
|
logger.debug(f"Reset filter failure count back to zero")
|
||||||
|
|
||||||
self.datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c})
|
self.datastore.update_watch(uuid=uuid, update_obj={'consecutive_filter_failures': c})
|
||||||
|
else:
|
||||||
|
logger.trace(f"{uuid} - filter_failure_notification_send not enabled, skipping")
|
||||||
|
|
||||||
|
|
||||||
process_changedetection_results = False
|
process_changedetection_results = False
|
||||||
|
|
||||||
@@ -422,7 +428,7 @@ class update_worker(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if watch.get('filter_failure_notification_send', False):
|
if watch.get('filter_failure_notification_send', False):
|
||||||
c = watch.get('consecutive_filter_failures', 5)
|
c = watch.get('consecutive_filter_failures', 0)
|
||||||
c += 1
|
c += 1
|
||||||
# Send notification if we reached the threshold?
|
# Send notification if we reached the threshold?
|
||||||
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts',
|
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ services:
|
|||||||
#
|
#
|
||||||
# Log levels are in descending order. (TRACE is the most detailed one)
|
# Log levels are in descending order. (TRACE is the most detailed one)
|
||||||
# Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL
|
# Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL
|
||||||
# - LOGGER_LEVEL=DEBUG
|
# - LOGGER_LEVEL=TRACE
|
||||||
#
|
#
|
||||||
# Alternative WebDriver/selenium URL, do not use "'s or 's!
|
# Alternative WebDriver/selenium URL, do not use "'s or 's!
|
||||||
# - WEBDRIVER_URL=http://browser-chrome:4444/wd/hub
|
# - WEBDRIVER_URL=http://browser-chrome:4444/wd/hub
|
||||||
@@ -29,8 +29,9 @@ services:
|
|||||||
#
|
#
|
||||||
# https://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.proxy
|
# https://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.proxy
|
||||||
#
|
#
|
||||||
# Alternative Playwright URL, do not use "'s or 's!
|
# Alternative target "Chrome" Playwright URL, do not use "'s or 's!
|
||||||
# - PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000
|
# "Playwright" is a driver/librarythat allows changedetection to talk to a Chrome or similar browser.
|
||||||
|
# - PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000
|
||||||
#
|
#
|
||||||
# Playwright proxy settings playwright_proxy_server, playwright_proxy_bypass, playwright_proxy_username, playwright_proxy_password
|
# Playwright proxy settings playwright_proxy_server, playwright_proxy_bypass, playwright_proxy_username, playwright_proxy_password
|
||||||
#
|
#
|
||||||
@@ -73,10 +74,10 @@ services:
|
|||||||
# condition: service_started
|
# condition: service_started
|
||||||
|
|
||||||
|
|
||||||
# Used for fetching pages via Playwright+Chrome where you need Javascript support.
|
# Sockpuppetbrowser is basically chrome wrapped in an API for allowing fast fetching of web-pages.
|
||||||
# RECOMMENDED FOR FETCHING PAGES WITH CHROME
|
# RECOMMENDED FOR FETCHING PAGES WITH CHROME
|
||||||
# playwright-chrome:
|
# sockpuppetbrowser:
|
||||||
# hostname: playwright-chrome
|
# hostname: sockpuppetbrowser
|
||||||
# image: dgtlmoon/sockpuppetbrowser:latest
|
# image: dgtlmoon/sockpuppetbrowser:latest
|
||||||
# cap_add:
|
# cap_add:
|
||||||
# - SYS_ADMIN
|
# - SYS_ADMIN
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ dnspython==2.6.1 # related to eventlet fixes
|
|||||||
# jq not available on Windows so must be installed manually
|
# jq not available on Windows so must be installed manually
|
||||||
|
|
||||||
# Notification library
|
# Notification library
|
||||||
apprise~=1.8.1
|
apprise==1.9.0
|
||||||
|
|
||||||
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
|
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
|
||||||
# and 2.0.0 https://github.com/dgtlmoon/changedetection.io/issues/2241 not yet compatible
|
# and 2.0.0 https://github.com/dgtlmoon/changedetection.io/issues/2241 not yet compatible
|
||||||
@@ -79,8 +79,9 @@ pyppeteerstealth>=0.0.4
|
|||||||
pytest ~=7.2
|
pytest ~=7.2
|
||||||
pytest-flask ~=1.2
|
pytest-flask ~=1.2
|
||||||
|
|
||||||
# Pin jsonschema version to prevent build errors on armv6 while rpds-py wheels aren't available (1708)
|
# Anything 4.0 and up but not 5.0
|
||||||
jsonschema==4.17.3
|
jsonschema ~= 4.0
|
||||||
|
|
||||||
|
|
||||||
loguru
|
loguru
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user