mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-14 21:46:14 +00:00
Compare commits
1 Commits
2548-trigg
...
2568-fix-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f3c12d2e |
@@ -37,7 +37,6 @@ RUN pip install --target=/dependencies playwright~=1.41.2 \
|
|||||||
|
|
||||||
# Final image stage
|
# Final image stage
|
||||||
FROM python:${PYTHON_VERSION}-slim-bookworm
|
FROM python:${PYTHON_VERSION}-slim-bookworm
|
||||||
LABEL org.opencontainers.image.source="https://github.com/dgtlmoon/changedetection.io"
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libxslt1.1 \
|
libxslt1.1 \
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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/model *
|
recursive-include changedetectionio/model *
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||||
|
|
||||||
__version__ = '0.47.03'
|
__version__ = '0.46.02'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class Watch(Resource):
|
|||||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||||
|
|
||||||
if request.args.get('recheck'):
|
if request.args.get('recheck'):
|
||||||
self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
|
||||||
return "OK", 200
|
return "OK", 200
|
||||||
if request.args.get('paused', '') == 'paused':
|
if request.args.get('paused', '') == 'paused':
|
||||||
self.datastore.data['watching'].get(uuid).pause()
|
self.datastore.data['watching'].get(uuid).pause()
|
||||||
@@ -246,7 +246,7 @@ class CreateWatch(Resource):
|
|||||||
|
|
||||||
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
|
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
|
||||||
if new_uuid:
|
if new_uuid:
|
||||||
self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
|
self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid, 'skip_when_checksum_same': True}))
|
||||||
return {'uuid': new_uuid}, 201
|
return {'uuid': new_uuid}, 201
|
||||||
else:
|
else:
|
||||||
return "Invalid or unsupported URL", 400
|
return "Invalid or unsupported URL", 400
|
||||||
@@ -303,7 +303,7 @@ class CreateWatch(Resource):
|
|||||||
|
|
||||||
if request.args.get('recheck_all'):
|
if request.args.get('recheck_all'):
|
||||||
for uuid in self.datastore.data['watching'].keys():
|
for uuid in self.datastore.data['watching'].keys():
|
||||||
self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
|
||||||
return {'status': "OK"}, 200
|
return {'status': "OK"}, 200
|
||||||
|
|
||||||
return list, 200
|
return list, 200
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
# include the decorator
|
|
||||||
from apprise.decorators import notify
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
@notify(on="delete")
|
|
||||||
@notify(on="deletes")
|
|
||||||
@notify(on="get")
|
|
||||||
@notify(on="gets")
|
|
||||||
@notify(on="post")
|
|
||||||
@notify(on="posts")
|
|
||||||
@notify(on="put")
|
|
||||||
@notify(on="puts")
|
|
||||||
def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
from apprise.utils import parse_url as apprise_parse_url
|
|
||||||
from apprise import URLBase
|
|
||||||
|
|
||||||
url = kwargs['meta'].get('url')
|
|
||||||
|
|
||||||
if url.startswith('post'):
|
|
||||||
r = requests.post
|
|
||||||
elif url.startswith('get'):
|
|
||||||
r = requests.get
|
|
||||||
elif url.startswith('put'):
|
|
||||||
r = requests.put
|
|
||||||
elif url.startswith('delete'):
|
|
||||||
r = requests.delete
|
|
||||||
|
|
||||||
url = url.replace('post://', 'http://')
|
|
||||||
url = url.replace('posts://', 'https://')
|
|
||||||
url = url.replace('put://', 'http://')
|
|
||||||
url = url.replace('puts://', 'https://')
|
|
||||||
url = url.replace('get://', 'http://')
|
|
||||||
url = url.replace('gets://', 'https://')
|
|
||||||
url = url.replace('put://', 'http://')
|
|
||||||
url = url.replace('puts://', 'https://')
|
|
||||||
url = url.replace('delete://', 'http://')
|
|
||||||
url = url.replace('deletes://', 'https://')
|
|
||||||
|
|
||||||
headers = {}
|
|
||||||
params = {}
|
|
||||||
auth = None
|
|
||||||
|
|
||||||
# Convert /foobar?+some-header=hello to proper header dictionary
|
|
||||||
results = apprise_parse_url(url)
|
|
||||||
if results:
|
|
||||||
# Add our headers that the user can potentially over-ride if they wish
|
|
||||||
# to to our returned result set and tidy entries by unquoting them
|
|
||||||
headers = {URLBase.unquote(x): URLBase.unquote(y)
|
|
||||||
for x, y in results['qsd+'].items()}
|
|
||||||
|
|
||||||
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
|
|
||||||
# In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise
|
|
||||||
# but here we are making straight requests, so we need todo convert this against apprise's logic
|
|
||||||
for k, v in results['qsd'].items():
|
|
||||||
if not k.strip('+-') in results['qsd+'].keys():
|
|
||||||
params[URLBase.unquote(k)] = URLBase.unquote(v)
|
|
||||||
|
|
||||||
# Determine Authentication
|
|
||||||
auth = ''
|
|
||||||
if results.get('user') and results.get('password'):
|
|
||||||
auth = (URLBase.unquote(results.get('user')), URLBase.unquote(results.get('user')))
|
|
||||||
elif results.get('user'):
|
|
||||||
auth = (URLBase.unquote(results.get('user')))
|
|
||||||
|
|
||||||
# Try to auto-guess if it's JSON
|
|
||||||
h = 'application/json; charset=utf-8'
|
|
||||||
try:
|
|
||||||
json.loads(body)
|
|
||||||
headers['Content-Type'] = h
|
|
||||||
except ValueError as e:
|
|
||||||
logger.warning(f"Could not automatically add '{h}' header to the {kwargs['meta'].get('schema')}:// notification because the document failed to parse as JSON: {e}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
r(results.get('url'),
|
|
||||||
auth=auth,
|
|
||||||
data=body.encode('utf-8') if type(body) is str else body,
|
|
||||||
headers=headers,
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
@@ -85,8 +85,7 @@ 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
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ browser_step_ui_config = {'Choose one': '0 0',
|
|||||||
'Click element if exists': '1 0',
|
'Click element if exists': '1 0',
|
||||||
'Click element': '1 0',
|
'Click element': '1 0',
|
||||||
'Click element containing text': '0 1',
|
'Click element containing text': '0 1',
|
||||||
'Click element containing text if exists': '0 1',
|
|
||||||
'Enter text in field': '1 1',
|
'Enter text in field': '1 1',
|
||||||
'Execute JS': '0 1',
|
'Execute JS': '0 1',
|
||||||
# 'Extract text and use as filter': '1 0',
|
# 'Extract text and use as filter': '1 0',
|
||||||
@@ -97,24 +96,12 @@ class steppable_browser_interface():
|
|||||||
return self.action_goto_url(value=self.start_url)
|
return self.action_goto_url(value=self.start_url)
|
||||||
|
|
||||||
def action_click_element_containing_text(self, selector=None, value=''):
|
def action_click_element_containing_text(self, selector=None, value=''):
|
||||||
logger.debug("Clicking element containing text")
|
|
||||||
if not len(value.strip()):
|
if not len(value.strip()):
|
||||||
return
|
return
|
||||||
elem = self.page.get_by_text(value)
|
elem = self.page.get_by_text(value)
|
||||||
if elem.count():
|
if elem.count():
|
||||||
elem.first.click(delay=randint(200, 500), timeout=3000)
|
elem.first.click(delay=randint(200, 500), timeout=3000)
|
||||||
|
|
||||||
def action_click_element_containing_text_if_exists(self, selector=None, value=''):
|
|
||||||
logger.debug("Clicking element containing text if exists")
|
|
||||||
if not len(value.strip()):
|
|
||||||
return
|
|
||||||
elem = self.page.get_by_text(value)
|
|
||||||
logger.debug(f"Clicking element containing text - {elem.count()} elements found")
|
|
||||||
if elem.count():
|
|
||||||
elem.first.click(delay=randint(200, 500), timeout=3000)
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
def action_enter_text_in_field(self, selector, value):
|
def action_enter_text_in_field(self, selector, value):
|
||||||
if not len(selector.strip()):
|
if not len(selector.strip()):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import importlib
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
|
|
||||||
from changedetectionio.store import ChangeDetectionStore
|
from changedetectionio.store import ChangeDetectionStore
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -33,6 +30,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
def long_task(uuid, preferred_proxy):
|
def long_task(uuid, preferred_proxy):
|
||||||
import time
|
import time
|
||||||
from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions
|
from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions
|
||||||
|
from changedetectionio.processors.text_json_diff import text_json_diff
|
||||||
from changedetectionio.safe_jinja import render as jinja_render
|
from changedetectionio.safe_jinja import render as jinja_render
|
||||||
|
|
||||||
status = {'status': '', 'length': 0, 'text': ''}
|
status = {'status': '', 'length': 0, 'text': ''}
|
||||||
@@ -40,12 +38,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
contents = ''
|
contents = ''
|
||||||
now = time.time()
|
now = time.time()
|
||||||
try:
|
try:
|
||||||
processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
|
update_handler = text_json_diff.perform_site_check(datastore=datastore, watch_uuid=uuid)
|
||||||
update_handler = processor_module.perform_site_check(datastore=datastore,
|
update_handler.call_browser()
|
||||||
watch_uuid=uuid
|
|
||||||
)
|
|
||||||
|
|
||||||
update_handler.call_browser(preferred_proxy_id=preferred_proxy)
|
|
||||||
# title, size is len contents not len xfer
|
# title, size is len contents not len xfer
|
||||||
except content_fetcher_exceptions.Non200ErrorCodeReceived as e:
|
except content_fetcher_exceptions.Non200ErrorCodeReceived as e:
|
||||||
if e.status_code == 404:
|
if e.status_code == 404:
|
||||||
@@ -54,7 +48,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
status.update({'status': 'ERROR', 'length': len(contents), 'text': f"{e.status_code} - Access denied"})
|
status.update({'status': 'ERROR', 'length': len(contents), 'text': f"{e.status_code} - Access denied"})
|
||||||
else:
|
else:
|
||||||
status.update({'status': 'ERROR', 'length': len(contents), 'text': f"Status code: {e.status_code}"})
|
status.update({'status': 'ERROR', 'length': len(contents), 'text': f"Status code: {e.status_code}"})
|
||||||
except FilterNotFoundInResponse:
|
except text_json_diff.FilterNotFoundInResponse:
|
||||||
status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but CSS/xPath filter not found (page changed layout?)"})
|
status.update({'status': 'OK', 'length': len(contents), 'text': f"OK but CSS/xPath filter not found (page changed layout?)"})
|
||||||
except content_fetcher_exceptions.EmptyReply as e:
|
except content_fetcher_exceptions.EmptyReply as e:
|
||||||
if e.status_code == 403 or e.status_code == 401:
|
if e.status_code == 403 or e.status_code == 401:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
|
|||||||
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
|
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
|
||||||
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, 'skip_when_checksum_same': False}))
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
|
||||||
|
<!--<script src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script>-->
|
||||||
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
|
||||||
|
|
||||||
<div class="edit-form monospaced-textarea">
|
<div class="edit-form monospaced-textarea">
|
||||||
@@ -57,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 CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
|
<span class="pure-form-message-inline">One rule 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">
|
<ul>
|
||||||
<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>
|
||||||
@@ -88,13 +89,11 @@ xpath://body/div/span[contains(@class, 'example-class')]",
|
|||||||
{{ render_field(form.subtractive_selectors, rows=5, placeholder="header
|
{{ render_field(form.subtractive_selectors, rows=5, placeholder="header
|
||||||
footer
|
footer
|
||||||
nav
|
nav
|
||||||
.stockticker
|
.stockticker") }}
|
||||||
//*[contains(text(), 'Advertisement')]") }}
|
|
||||||
<span class="pure-form-message-inline">
|
<span class="pure-form-message-inline">
|
||||||
<ul>
|
<ul>
|
||||||
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
|
<li> Remove HTML element(s) by CSS selector before text conversion. </li>
|
||||||
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
|
<li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
|
||||||
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ from loguru import logger
|
|||||||
from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException
|
from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# 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'
|
||||||
visualselector_xpath_selectors = 'div,span,form,table,tbody,tr,td,a,p,ul,li,h1,h2,h3,h4,header,footer,section,article,aside,details,main,nav,section,summary,button'
|
|
||||||
|
|
||||||
|
|
||||||
# available_fetchers() will scan this implementation looking for anything starting with html_
|
# 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
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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
|
||||||
@@ -26,9 +28,6 @@ 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,7 +74,6 @@ class fetcher(Fetcher):
|
|||||||
self.headers = r.headers
|
self.headers = r.headers
|
||||||
|
|
||||||
if not r.content or not len(r.content):
|
if not r.content or not len(r.content):
|
||||||
logger.debug(f"Requests returned empty content for '{url}'")
|
|
||||||
if not empty_pages_are_a_change:
|
if not empty_pages_are_a_change:
|
||||||
raise EmptyReply(url=url, status_code=r.status_code)
|
raise EmptyReply(url=url, status_code=r.status_code)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ 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.',
|
||||||
@@ -154,15 +153,11 @@ function isItemInStock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elementText = "";
|
elementText = "";
|
||||||
try {
|
|
||||||
if (element.tagName.toLowerCase() === "input") {
|
if (element.tagName.toLowerCase() === "input") {
|
||||||
elementText = element.value.toLowerCase().trim();
|
elementText = element.value.toLowerCase().trim();
|
||||||
} else {
|
} else {
|
||||||
elementText = getElementBaseText(element);
|
elementText = getElementBaseText(element);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.warn('stock-not-in-stock.js scraper - handling element for gettext failed', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elementText.length) {
|
if (elementText.length) {
|
||||||
// try which ones could mean its in stock
|
// try which ones could mean its in stock
|
||||||
@@ -178,8 +173,7 @@ 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
|
||||||
// Note: theres also an automated test that places the 'out of stock' text fairly low down
|
if (element.getBoundingClientRect().top + window.scrollY >= vh + 150 || element.getBoundingClientRect().top + window.scrollY <= 100) {
|
||||||
if (element.getBoundingClientRect().top + window.scrollY >= vh + 250 || element.getBoundingClientRect().top + window.scrollY <= 100) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
elementText = "";
|
elementText = "";
|
||||||
@@ -193,7 +187,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}" - offset top ${element.getBoundingClientRect().top}, page height is ${vh}`)
|
console.log(`Selected 'Out of Stock' - found text "${outOfStockText}" - "${elementText}"`)
|
||||||
return outOfStockText; // item is out of stock
|
return outOfStockText; // item is out of stock
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,15 +164,6 @@ 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,
|
||||||
@@ -180,16 +171,9 @@ 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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import flask_login
|
import flask_login
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
@@ -538,8 +537,7 @@ 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'
|
||||||
|
|
||||||
@@ -788,6 +786,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
# Recast it if need be to right data Watch handler
|
# Recast it if need be to right data Watch handler
|
||||||
watch_class = get_custom_watch_obj_for_processor(form.data.get('processor'))
|
watch_class = get_custom_watch_obj_for_processor(form.data.get('processor'))
|
||||||
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore_o.datastore_path, default=datastore.data['watching'][uuid])
|
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore_o.datastore_path, default=datastore.data['watching'][uuid])
|
||||||
|
|
||||||
flash("Updated watch - unpaused!" if request.args.get('unpause_on_save') else "Updated watch.")
|
flash("Updated watch - unpaused!" if request.args.get('unpause_on_save') else "Updated watch.")
|
||||||
|
|
||||||
# Re #286 - We wait for syncing new data to disk in another thread every 60 seconds
|
# Re #286 - We wait for syncing new data to disk in another thread every 60 seconds
|
||||||
@@ -795,7 +794,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
datastore.needs_write_urgent = True
|
datastore.needs_write_urgent = True
|
||||||
|
|
||||||
# Queue the watch for immediate recheck, with a higher priority
|
# Queue the watch for immediate recheck, with a higher priority
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False}))
|
||||||
|
|
||||||
# Diff page [edit] link should go back to diff page
|
# Diff page [edit] link should go back to diff page
|
||||||
if request.args.get("next") and request.args.get("next") == 'diff':
|
if request.args.get("next") and request.args.get("next") == 'diff':
|
||||||
@@ -976,7 +975,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
importer = import_url_list()
|
importer = import_url_list()
|
||||||
importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff'))
|
importer.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff'))
|
||||||
for uuid in importer.new_uuids:
|
for uuid in importer.new_uuids:
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
|
||||||
|
|
||||||
if len(importer.remaining_data) == 0:
|
if len(importer.remaining_data) == 0:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
@@ -989,7 +988,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
d_importer = import_distill_io_json()
|
d_importer = import_distill_io_json()
|
||||||
d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore)
|
d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore)
|
||||||
for uuid in d_importer.new_uuids:
|
for uuid in d_importer.new_uuids:
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
|
||||||
|
|
||||||
# XLSX importer
|
# XLSX importer
|
||||||
if request.files and request.files.get('xlsx_file'):
|
if request.files and request.files.get('xlsx_file'):
|
||||||
@@ -1013,7 +1012,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
w_importer.run(data=file, flash=flash, datastore=datastore)
|
w_importer.run(data=file, flash=flash, datastore=datastore)
|
||||||
|
|
||||||
for uuid in w_importer.new_uuids:
|
for uuid in w_importer.new_uuids:
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': True}))
|
||||||
|
|
||||||
# Could be some remaining, or we could be on GET
|
# Could be some remaining, or we could be on GET
|
||||||
form = forms.importForm(formdata=request.form if request.method == 'POST' else None)
|
form = forms.importForm(formdata=request.form if request.method == 'POST' else None)
|
||||||
@@ -1154,6 +1153,8 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
def preview_page(uuid):
|
def preview_page(uuid):
|
||||||
content = []
|
content = []
|
||||||
|
ignored_line_numbers = []
|
||||||
|
trigger_line_numbers = []
|
||||||
versions = []
|
versions = []
|
||||||
timestamp = None
|
timestamp = None
|
||||||
|
|
||||||
@@ -1170,10 +1171,11 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
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')]
|
||||||
|
|
||||||
|
|
||||||
is_html_webdriver = False
|
is_html_webdriver = False
|
||||||
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
|
if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'):
|
||||||
is_html_webdriver = True
|
is_html_webdriver = True
|
||||||
triggered_line_numbers = []
|
|
||||||
if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):
|
if datastore.data['watching'][uuid].history_n == 0 and (watch.get_error_text() or watch.get_error_snapshot()):
|
||||||
flash("Preview unavailable - No fetch/check completed or triggers not reached", "error")
|
flash("Preview unavailable - No fetch/check completed or triggers not reached", "error")
|
||||||
else:
|
else:
|
||||||
@@ -1186,12 +1188,31 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
versions = list(watch.history.keys())
|
versions = list(watch.history.keys())
|
||||||
content = watch.get_history_snapshot(timestamp)
|
tmp = watch.get_history_snapshot(timestamp).splitlines()
|
||||||
|
|
||||||
triggered_line_numbers = html_tools.strip_ignore_text(content=content,
|
# Get what needs to be highlighted
|
||||||
|
ignore_rules = watch.get('ignore_text', []) + datastore.data['settings']['application']['global_ignore_text']
|
||||||
|
|
||||||
|
# .readlines will keep the \n, but we will parse it here again, in the future tidy this up
|
||||||
|
ignored_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp),
|
||||||
|
wordlist=ignore_rules,
|
||||||
|
mode='line numbers'
|
||||||
|
)
|
||||||
|
|
||||||
|
trigger_line_numbers = html_tools.strip_ignore_text(content="\n".join(tmp),
|
||||||
wordlist=watch['trigger_text'],
|
wordlist=watch['trigger_text'],
|
||||||
mode='line numbers'
|
mode='line numbers'
|
||||||
)
|
)
|
||||||
|
# Prepare the classes and lines used in the template
|
||||||
|
i=0
|
||||||
|
for l in tmp:
|
||||||
|
classes=[]
|
||||||
|
i+=1
|
||||||
|
if i in ignored_line_numbers:
|
||||||
|
classes.append('ignored')
|
||||||
|
if i in trigger_line_numbers:
|
||||||
|
classes.append('triggered')
|
||||||
|
content.append({'line': l, 'classes': ' '.join(classes)})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''})
|
content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''})
|
||||||
@@ -1202,7 +1223,8 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
history_n=watch.history_n,
|
history_n=watch.history_n,
|
||||||
extra_stylesheets=extra_stylesheets,
|
extra_stylesheets=extra_stylesheets,
|
||||||
extra_title=f" - Diff - {watch.label} @ {timestamp}",
|
extra_title=f" - Diff - {watch.label} @ {timestamp}",
|
||||||
triggered_line_numbers=triggered_line_numbers,
|
ignored_line_numbers=ignored_line_numbers,
|
||||||
|
triggered_line_numbers=trigger_line_numbers,
|
||||||
current_diff_url=watch['url'],
|
current_diff_url=watch['url'],
|
||||||
screenshot=watch.get_screenshot(),
|
screenshot=watch.get_screenshot(),
|
||||||
watch=watch,
|
watch=watch,
|
||||||
@@ -1355,15 +1377,13 @@ 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 watch.history.keys() and os.path.isdir(watch.watch_data_dir):
|
if watch and os.path.isdir(watch.watch_data_dir):
|
||||||
latest_filename = list(watch.history.keys())[-1]
|
latest_filename = list(watch.history.keys())[0]
|
||||||
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")
|
||||||
with open(html_fname, 'rb') as f:
|
|
||||||
if html_fname.endswith('.br'):
|
if html_fname.endswith('.br'):
|
||||||
# Read and decompress the Brotli file
|
# Read and decompress the Brotli file
|
||||||
|
with open(html_fname, 'rb') as f:
|
||||||
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)
|
||||||
|
|
||||||
@@ -1373,15 +1393,6 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
# Return a 500 error
|
# Return a 500 error
|
||||||
abort(500)
|
abort(500)
|
||||||
|
|
||||||
# Ajax callback
|
|
||||||
@app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])
|
|
||||||
@login_optionally_required
|
|
||||||
def watch_get_preview_rendered(uuid):
|
|
||||||
'''For when viewing the "preview" of the rendered text from inside of Edit'''
|
|
||||||
from .processors.text_json_diff import prepare_filter_prevew
|
|
||||||
return prepare_filter_prevew(watch_uuid=uuid, datastore=datastore)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/form/add/quickwatch", methods=['POST'])
|
@app.route("/form/add/quickwatch", methods=['POST'])
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
def form_quick_watch_add():
|
def form_quick_watch_add():
|
||||||
@@ -1442,7 +1453,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
new_uuid = datastore.clone(uuid)
|
new_uuid = datastore.clone(uuid)
|
||||||
if new_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, 'skip_when_checksum_same': True}))
|
||||||
flash('Cloned.')
|
flash('Cloned.')
|
||||||
|
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
@@ -1463,7 +1474,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
|
|
||||||
if uuid:
|
if uuid:
|
||||||
if uuid not in running_uuids:
|
if uuid not in running_uuids:
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False}))
|
||||||
i = 1
|
i = 1
|
||||||
|
|
||||||
elif tag:
|
elif tag:
|
||||||
@@ -1474,7 +1485,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
continue
|
continue
|
||||||
if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
|
if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
|
||||||
update_q.put(
|
update_q.put(
|
||||||
queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid})
|
queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False})
|
||||||
)
|
)
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
@@ -1484,8 +1495,9 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
|
if watch_uuid not in running_uuids and not datastore.data['watching'][watch_uuid]['paused']:
|
||||||
if with_errors and not watch.get('last_error'):
|
if with_errors and not watch.get('last_error'):
|
||||||
continue
|
continue
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid, 'skip_when_checksum_same': False}))
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
flash(f"{i} watches queued for rechecking.")
|
flash(f"{i} watches queued for rechecking.")
|
||||||
return redirect(url_for('index', tag=tag))
|
return redirect(url_for('index', tag=tag))
|
||||||
|
|
||||||
@@ -1542,7 +1554,7 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
uuid = uuid.strip()
|
uuid = uuid.strip()
|
||||||
if datastore.data['watching'].get(uuid):
|
if datastore.data['watching'].get(uuid):
|
||||||
# Recheck and require a full reprocessing
|
# Recheck and require a full reprocessing
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid, 'skip_when_checksum_same': False}))
|
||||||
flash("{} watches queued for rechecking".format(len(uuids)))
|
flash("{} watches queued for rechecking".format(len(uuids)))
|
||||||
|
|
||||||
elif (op == 'clear-errors'):
|
elif (op == 'clear-errors'):
|
||||||
@@ -1866,7 +1878,7 @@ def ticker_thread_check_time_launch_checks():
|
|||||||
f"{now - watch['last_checked']:0.2f}s since last checked")
|
f"{now - watch['last_checked']:0.2f}s since last checked")
|
||||||
|
|
||||||
# Into the queue with you
|
# Into the queue with you
|
||||||
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=priority, item={'uuid': uuid}))
|
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=priority, item={'uuid': uuid, 'skip_when_checksum_same': True}))
|
||||||
|
|
||||||
# Reset for next time
|
# Reset for next time
|
||||||
watch.jitter_seconds = 0
|
watch.jitter_seconds = 0
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
|
|
||||||
@@ -222,8 +221,7 @@ 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))
|
||||||
@@ -470,21 +468,19 @@ class processor_text_json_diff_form(commonSettingsForm):
|
|||||||
|
|
||||||
include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='')
|
include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='')
|
||||||
|
|
||||||
subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)])
|
subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)])
|
||||||
|
|
||||||
extract_text = StringListField('Extract text', [ValidateListRegex()])
|
extract_text = StringListField('Extract text', [ValidateListRegex()])
|
||||||
|
|
||||||
title = StringField('Title', default='')
|
title = StringField('Title', default='')
|
||||||
|
|
||||||
ignore_text = StringListField('Ignore lines containing', [ValidateListRegex()])
|
ignore_text = StringListField('Ignore text', [ValidateListRegex()])
|
||||||
headers = StringDictKeyValue('Request headers')
|
headers = StringDictKeyValue('Request headers')
|
||||||
body = TextAreaField('Request body', [validators.Optional()])
|
body = TextAreaField('Request body', [validators.Optional()])
|
||||||
method = SelectField('Request method', choices=valid_method, default=default_method)
|
method = SelectField('Request method', choices=valid_method, default=default_method)
|
||||||
ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False)
|
ignore_status_codes = BooleanField('Ignore status codes (process non-2xx status codes as normal)', default=False)
|
||||||
check_unique_lines = BooleanField('Only trigger when unique lines appear in all history', default=False)
|
check_unique_lines = BooleanField('Only trigger when unique lines appear', default=False)
|
||||||
remove_duplicate_lines = BooleanField('Remove duplicate lines of text', default=False)
|
|
||||||
sort_text_alphabetically = BooleanField('Sort text alphabetically', default=False)
|
sort_text_alphabetically = BooleanField('Sort text alphabetically', default=False)
|
||||||
trim_text_whitespace = BooleanField('Trim whitespace before and after text', default=False)
|
|
||||||
|
|
||||||
filter_text_added = BooleanField('Added lines', default=True)
|
filter_text_added = BooleanField('Added lines', default=True)
|
||||||
filter_text_replaced = BooleanField('Replaced/changed lines', default=True)
|
filter_text_replaced = BooleanField('Replaced/changed lines', default=True)
|
||||||
@@ -526,16 +522,9 @@ class processor_text_json_diff_form(commonSettingsForm):
|
|||||||
try:
|
try:
|
||||||
from changedetectionio.safe_jinja import render as jinja_render
|
from changedetectionio.safe_jinja import render as jinja_render
|
||||||
jinja_render(template_str=self.url.data)
|
jinja_render(template_str=self.url.data)
|
||||||
except ModuleNotFoundError as e:
|
|
||||||
# incase jinja2_time or others is missing
|
|
||||||
logger.error(e)
|
|
||||||
self.url.errors.append(e)
|
|
||||||
result = False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
|
||||||
self.url.errors.append('Invalid template syntax')
|
self.url.errors.append('Invalid template syntax')
|
||||||
result = False
|
result = False
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
class SingleExtraProxy(Form):
|
class SingleExtraProxy(Form):
|
||||||
@@ -586,7 +575,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
|||||||
empty_pages_are_a_change = BooleanField('Treat empty pages as a change?', default=False)
|
empty_pages_are_a_change = BooleanField('Treat empty pages as a change?', default=False)
|
||||||
fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
|
fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
|
||||||
global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()])
|
global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()])
|
||||||
global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)])
|
global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_xpath=False, allow_json=False)])
|
||||||
ignore_whitespace = BooleanField('Ignore whitespace')
|
ignore_whitespace = BooleanField('Ignore whitespace')
|
||||||
password = SaltyPasswordField()
|
password = SaltyPasswordField()
|
||||||
pager_size = IntegerField('Pager size',
|
pager_size = IntegerField('Pager size',
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from inscriptis import get_text
|
||||||
|
from jsonpath_ng.ext import parse
|
||||||
from typing import List
|
from typing import List
|
||||||
from lxml import etree
|
from inscriptis.model.config import ParserConfig
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
# HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis
|
# HTML added to be sure each result matching a filter (.example) gets converted to a new line by Inscriptis
|
||||||
TEXT_FILTER_LIST_LINE_SUFFIX = "<br>"
|
TEXT_FILTER_LIST_LINE_SUFFIX = "<br>"
|
||||||
TRANSLATE_WHITESPACE_TABLE = str.maketrans('', '', '\r\n\t ')
|
|
||||||
PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$'
|
|
||||||
|
|
||||||
|
PERL_STYLE_REGEX = r'^/(.*?)/([a-z]*)?$'
|
||||||
# 'price' , 'lowPrice', 'highPrice' are usually under here
|
# 'price' , 'lowPrice', 'highPrice' are usually under here
|
||||||
# All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here
|
# All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here
|
||||||
LD_JSON_PRODUCT_OFFER_SELECTORS = ["json:$..offers", "json:$..Offers"]
|
LD_JSON_PRODUCT_OFFER_SELECTORS = ["json:$..offers", "json:$..Offers"]
|
||||||
@@ -34,7 +39,6 @@ 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="")
|
||||||
@@ -52,32 +56,16 @@ 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()
|
||||||
return str(soup)
|
return str(soup)
|
||||||
|
|
||||||
def subtractive_xpath_selector(xpath_selector, html_content):
|
|
||||||
html_tree = etree.HTML(html_content)
|
|
||||||
elements_to_remove = html_tree.xpath(xpath_selector)
|
|
||||||
|
|
||||||
for element in elements_to_remove:
|
|
||||||
element.getparent().remove(element)
|
|
||||||
|
|
||||||
modified_html = etree.tostring(html_tree, method="html").decode("utf-8")
|
|
||||||
return modified_html
|
|
||||||
|
|
||||||
def element_removal(selectors: List[str], html_content):
|
def element_removal(selectors: List[str], html_content):
|
||||||
"""Removes elements that match a list of CSS or xPath selectors."""
|
"""Joins individual filters into one css filter."""
|
||||||
modified_html = html_content
|
selector = ",".join(selectors)
|
||||||
for selector in selectors:
|
return subtractive_css_selector(selector, html_content)
|
||||||
if selector.startswith(('xpath:', 'xpath1:', '//')):
|
|
||||||
xpath_selector = selector.removeprefix('xpath:').removeprefix('xpath1:')
|
|
||||||
modified_html = subtractive_xpath_selector(xpath_selector, modified_html)
|
|
||||||
else:
|
|
||||||
modified_html = subtractive_css_selector(selector, modified_html)
|
|
||||||
return modified_html
|
|
||||||
|
|
||||||
def elementpath_tostring(obj):
|
def elementpath_tostring(obj):
|
||||||
"""
|
"""
|
||||||
@@ -193,7 +181,6 @@ 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
|
||||||
@@ -207,8 +194,6 @@ 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)
|
||||||
@@ -257,8 +242,6 @@ 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
|
||||||
@@ -326,7 +309,6 @@ def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None
|
|||||||
# - "line numbers" return a list of line numbers that match (int list)
|
# - "line numbers" return a list of line numbers that match (int list)
|
||||||
#
|
#
|
||||||
# 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
|
|
||||||
def strip_ignore_text(content, wordlist, mode="content"):
|
def strip_ignore_text(content, wordlist, mode="content"):
|
||||||
i = 0
|
i = 0
|
||||||
output = []
|
output = []
|
||||||
@@ -342,10 +324,11 @@ def strip_ignore_text(content, wordlist, mode="content"):
|
|||||||
else:
|
else:
|
||||||
ignore_text.append(k.strip())
|
ignore_text.append(k.strip())
|
||||||
|
|
||||||
for line in content.splitlines(keepends=True):
|
for line in content.splitlines():
|
||||||
i += 1
|
i += 1
|
||||||
# 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
|
||||||
|
if len(line.strip()):
|
||||||
for l in ignore_text:
|
for l in ignore_text:
|
||||||
if l.lower() in line.lower():
|
if l.lower() in line.lower():
|
||||||
got_match = True
|
got_match = True
|
||||||
@@ -356,19 +339,19 @@ def strip_ignore_text(content, wordlist, mode="content"):
|
|||||||
got_match = True
|
got_match = True
|
||||||
|
|
||||||
if not got_match:
|
if not got_match:
|
||||||
# Not ignored, and should preserve "keepends"
|
# Not ignored
|
||||||
output.append(line)
|
output.append(line.encode('utf8'))
|
||||||
else:
|
else:
|
||||||
ignored_line_numbers.append(i)
|
ignored_line_numbers.append(i)
|
||||||
|
|
||||||
|
|
||||||
# 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 ignored_line_numbers
|
||||||
|
|
||||||
return ''.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)
|
||||||
@@ -377,9 +360,6 @@ 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
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
|
|
||||||
|
|
||||||
# Allowable protocols, protects against javascript: etc
|
# Allowable protocols, protects against javascript: etc
|
||||||
# file:// is further checked by ALLOW_FILE_URI
|
# file:// is further checked by ALLOW_FILE_URI
|
||||||
SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):'
|
SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):'
|
||||||
@@ -38,8 +36,7 @@ class model(watch_base):
|
|||||||
jitter_seconds = 0
|
jitter_seconds = 0
|
||||||
|
|
||||||
def __init__(self, *arg, **kw):
|
def __init__(self, *arg, **kw):
|
||||||
self.__datastore_path = kw.get('datastore_path')
|
self.__datastore_path = kw['datastore_path']
|
||||||
if kw.get('datastore_path'):
|
|
||||||
del kw['datastore_path']
|
del kw['datastore_path']
|
||||||
super(model, self).__init__(*arg, **kw)
|
super(model, self).__init__(*arg, **kw)
|
||||||
if kw.get('default'):
|
if kw.get('default'):
|
||||||
@@ -174,10 +171,6 @@ class model(watch_base):
|
|||||||
"""
|
"""
|
||||||
tmp_history = {}
|
tmp_history = {}
|
||||||
|
|
||||||
# In the case we are only using the watch for processing without history
|
|
||||||
if not self.watch_data_dir:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Read the history file as a dict
|
# Read the history file as a dict
|
||||||
fname = os.path.join(self.watch_data_dir, "history.txt")
|
fname = os.path.join(self.watch_data_dir, "history.txt")
|
||||||
if os.path.isfile(fname):
|
if os.path.isfile(fname):
|
||||||
@@ -314,13 +307,13 @@ class model(watch_base):
|
|||||||
dest = os.path.join(self.watch_data_dir, snapshot_fname)
|
dest = os.path.join(self.watch_data_dir, snapshot_fname)
|
||||||
if not os.path.exists(dest):
|
if not os.path.exists(dest):
|
||||||
with open(dest, 'wb') as f:
|
with open(dest, 'wb') as f:
|
||||||
f.write(brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT))
|
f.write(brotli.compress(contents, mode=brotli.MODE_TEXT))
|
||||||
else:
|
else:
|
||||||
snapshot_fname = f"{snapshot_id}.txt"
|
snapshot_fname = f"{snapshot_id}.txt"
|
||||||
dest = os.path.join(self.watch_data_dir, snapshot_fname)
|
dest = os.path.join(self.watch_data_dir, snapshot_fname)
|
||||||
if not os.path.exists(dest):
|
if not os.path.exists(dest):
|
||||||
with open(dest, 'wb') as f:
|
with open(dest, 'wb') as f:
|
||||||
f.write(contents.encode('utf-8'))
|
f.write(contents)
|
||||||
|
|
||||||
# Append to index
|
# Append to index
|
||||||
# @todo check last char was \n
|
# @todo check last char was \n
|
||||||
@@ -352,32 +345,14 @@ class model(watch_base):
|
|||||||
return seconds
|
return seconds
|
||||||
|
|
||||||
# Iterate over all history texts and see if something new exists
|
# Iterate over all history texts and see if something new exists
|
||||||
# Always applying .strip() to start/end but optionally replace any other whitespace
|
def lines_contain_something_unique_compared_to_history(self, lines: list):
|
||||||
def lines_contain_something_unique_compared_to_history(self, lines: list, ignore_whitespace=False):
|
|
||||||
local_lines = []
|
|
||||||
if lines:
|
|
||||||
if ignore_whitespace:
|
|
||||||
if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk
|
|
||||||
local_lines = set([l.translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines])
|
|
||||||
else:
|
|
||||||
local_lines = set([l.decode('utf-8').translate(TRANSLATE_WHITESPACE_TABLE).lower() for l in lines])
|
|
||||||
else:
|
|
||||||
if isinstance(lines[0], str): # Can be either str or bytes depending on what was on the disk
|
|
||||||
local_lines = set([l.strip().lower() for l in lines])
|
|
||||||
else:
|
|
||||||
local_lines = set([l.decode('utf-8').strip().lower() for l in lines])
|
local_lines = set([l.decode('utf-8').strip().lower() for l in lines])
|
||||||
|
|
||||||
|
|
||||||
# Compare each lines (set) against each history text file (set) looking for something new..
|
# Compare each lines (set) against each history text file (set) looking for something new..
|
||||||
existing_history = set({})
|
existing_history = set({})
|
||||||
for k, v in self.history.items():
|
for k, v in self.history.items():
|
||||||
content = self.get_history_snapshot(k)
|
content = self.get_history_snapshot(k)
|
||||||
|
|
||||||
if ignore_whitespace:
|
|
||||||
alist = set([line.translate(TRANSLATE_WHITESPACE_TABLE).lower() for line in content.splitlines()])
|
|
||||||
else:
|
|
||||||
alist = set([line.strip().lower() for line in content.splitlines()])
|
alist = set([line.strip().lower() for line in content.splitlines()])
|
||||||
|
|
||||||
existing_history = existing_history.union(alist)
|
existing_history = existing_history.union(alist)
|
||||||
|
|
||||||
# Check that everything in local_lines(new stuff) already exists in existing_history - it should
|
# Check that everything in local_lines(new stuff) already exists in existing_history - it should
|
||||||
@@ -421,7 +396,7 @@ class model(watch_base):
|
|||||||
@property
|
@property
|
||||||
def watch_data_dir(self):
|
def watch_data_dir(self):
|
||||||
# The base dir of the watch data
|
# The base dir of the watch data
|
||||||
return os.path.join(self.__datastore_path, self['uuid']) if self.__datastore_path else None
|
return os.path.join(self.__datastore_path, self['uuid'])
|
||||||
|
|
||||||
def get_error_text(self):
|
def get_error_text(self):
|
||||||
"""Return the text saved from a previous request that resulted in a non-200 error"""
|
"""Return the text saved from a previous request that resulted in a non-200 error"""
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class watch_base(dict):
|
|||||||
'check_count': 0,
|
'check_count': 0,
|
||||||
'check_unique_lines': False, # On change-detected, compare against all history if its something new
|
'check_unique_lines': False, # On change-detected, compare against all history if its something new
|
||||||
'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
|
'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
|
||||||
'content-type': None,
|
|
||||||
'date_created': None,
|
'date_created': None,
|
||||||
'extract_text': [], # Extract text by regex after filters
|
'extract_text': [], # Extract text by regex after filters
|
||||||
'extract_title_as_title': False,
|
'extract_title_as_title': False,
|
||||||
@@ -61,8 +60,6 @@ class watch_base(dict):
|
|||||||
'time_between_check_use_default': True,
|
'time_between_check_use_default': True,
|
||||||
'title': None,
|
'title': None,
|
||||||
'track_ldjson_price_data': None,
|
'track_ldjson_price_data': None,
|
||||||
'trim_text_whitespace': False,
|
|
||||||
'remove_duplicate_lines': False,
|
|
||||||
'trigger_text': [], # List of text or regex to wait for until a change is detected
|
'trigger_text': [], # List of text or regex to wait for until a change is detected
|
||||||
'url': '',
|
'url': '',
|
||||||
'uuid': str(uuid.uuid4()),
|
'uuid': str(uuid.uuid4()),
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import apprise
|
||||||
import time
|
import time
|
||||||
from apprise import NotifyFormat
|
from apprise import NotifyFormat
|
||||||
import apprise
|
import json
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
valid_tokens = {
|
valid_tokens = {
|
||||||
'base_url': '',
|
'base_url': '',
|
||||||
'current_snapshot': '',
|
'current_snapshot': '',
|
||||||
@@ -35,11 +34,86 @@ 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,14 +1,14 @@
|
|||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from changedetectionio.content_fetchers.base import Fetcher
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import hashlib
|
import hashlib
|
||||||
import importlib
|
|
||||||
import inspect
|
|
||||||
import os
|
import os
|
||||||
import pkgutil
|
|
||||||
import re
|
import re
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
import inspect
|
||||||
|
|
||||||
class difference_detection_processor():
|
class difference_detection_processor():
|
||||||
|
|
||||||
@@ -18,18 +18,15 @@ class difference_detection_processor():
|
|||||||
screenshot = None
|
screenshot = None
|
||||||
watch = None
|
watch = None
|
||||||
xpath_data = None
|
xpath_data = None
|
||||||
preferred_proxy = None
|
|
||||||
|
|
||||||
def __init__(self, *args, datastore, watch_uuid, **kwargs):
|
def __init__(self, *args, datastore, watch_uuid, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.datastore = datastore
|
self.datastore = datastore
|
||||||
self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))
|
self.watch = deepcopy(self.datastore.data['watching'].get(watch_uuid))
|
||||||
# Generic fetcher that should be extended (requests, playwright etc)
|
|
||||||
self.fetcher = Fetcher()
|
|
||||||
|
|
||||||
def call_browser(self, preferred_proxy_id=None):
|
|
||||||
|
|
||||||
|
def call_browser(self):
|
||||||
from requests.structures import CaseInsensitiveDict
|
from requests.structures import CaseInsensitiveDict
|
||||||
|
from changedetectionio.content_fetchers.exceptions import EmptyReply
|
||||||
|
|
||||||
# Protect against file:// access
|
# Protect against file:// access
|
||||||
if re.search(r'^file://', self.watch.get('url', '').strip(), re.IGNORECASE):
|
if re.search(r'^file://', self.watch.get('url', '').strip(), re.IGNORECASE):
|
||||||
@@ -44,7 +41,7 @@ class difference_detection_processor():
|
|||||||
prefer_fetch_backend = self.watch.get('fetch_backend', 'system')
|
prefer_fetch_backend = self.watch.get('fetch_backend', 'system')
|
||||||
|
|
||||||
# Proxy ID "key"
|
# Proxy ID "key"
|
||||||
preferred_proxy_id = preferred_proxy_id if preferred_proxy_id else self.datastore.get_preferred_proxy_for_watch(uuid=self.watch.get('uuid'))
|
preferred_proxy_id = self.datastore.get_preferred_proxy_for_watch(uuid=self.watch.get('uuid'))
|
||||||
|
|
||||||
# Pluggable content self.fetcher
|
# Pluggable content self.fetcher
|
||||||
if not prefer_fetch_backend or prefer_fetch_backend == 'system':
|
if not prefer_fetch_backend or prefer_fetch_backend == 'system':
|
||||||
@@ -157,7 +154,7 @@ class difference_detection_processor():
|
|||||||
# After init, call run_changedetection() which will do the actual change-detection
|
# After init, call run_changedetection() which will do the actual change-detection
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def run_changedetection(self, watch):
|
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
||||||
update_obj = {'last_notification_error': False, 'last_error': False}
|
update_obj = {'last_notification_error': False, 'last_error': False}
|
||||||
some_data = 'xxxxx'
|
some_data = 'xxxxx'
|
||||||
update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()
|
update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
|
|
||||||
from babel.numbers import parse_decimal
|
|
||||||
from changedetectionio.model.Watch import model as BaseWatch
|
from changedetectionio.model.Watch import model as BaseWatch
|
||||||
from typing import Union
|
|
||||||
import re
|
import re
|
||||||
|
from babel.numbers import parse_decimal
|
||||||
|
|
||||||
class Restock(dict):
|
class Restock(dict):
|
||||||
|
|
||||||
def parse_currency(self, raw_value: str) -> Union[float, None]:
|
def parse_currency(self, raw_value: str) -> float:
|
||||||
# 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
|
||||||
|
|
||||||
@@ -22,12 +21,9 @@ 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)
|
||||||
|
|
||||||
if standardized_value:
|
|
||||||
# Convert to float
|
# Convert to float
|
||||||
return float(parse_decimal(standardized_value, locale='en'))
|
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
|
||||||
default_values = {
|
default_values = {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ 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
|
||||||
|
|
||||||
@@ -26,30 +27,6 @@ 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):
|
|
||||||
import re
|
|
||||||
|
|
||||||
'''
|
|
||||||
Some price data has multiple entries, OR it has a single entry with ['$159', '159', 159, "$ 159"] or just "159"
|
|
||||||
Get all the values, clean it and add it to a set then return the unique values
|
|
||||||
'''
|
|
||||||
unique_data = set()
|
|
||||||
|
|
||||||
# Return the complete 'datum' where its price was not seen before
|
|
||||||
for datum in data:
|
|
||||||
|
|
||||||
if isinstance(datum.value, list):
|
|
||||||
# Process each item in the list
|
|
||||||
normalized_value = set([float(re.sub(r'[^\d.]', '', str(item))) for item in datum.value])
|
|
||||||
unique_data.update(normalized_value)
|
|
||||||
else:
|
|
||||||
# Process single value
|
|
||||||
v = float(re.sub(r'[^\d.]', '', str(datum.value)))
|
|
||||||
unique_data.add(v)
|
|
||||||
|
|
||||||
return list(unique_data)
|
|
||||||
|
|
||||||
|
|
||||||
# should return Restock()
|
# should return Restock()
|
||||||
# add casting?
|
# add casting?
|
||||||
def get_itemprop_availability(html_content) -> Restock:
|
def get_itemprop_availability(html_content) -> Restock:
|
||||||
@@ -59,21 +36,17 @@ 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']
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
syntaxes = ['dublincore', 'json-ld', 'microdata', 'microformat', 'opengraph']
|
||||||
|
|
||||||
|
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
|
||||||
@@ -84,17 +57,18 @@ 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 = _deduplicate_prices(price_parse.find(data))
|
price_result = 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?
|
||||||
if len(price_result) > 1 and len(price_result) > 1:
|
prices_found = set(str(item.value).replace('$', '') for item in price_result)
|
||||||
|
if len(price_result) > 1 and len(prices_found) > 1:
|
||||||
# See of all prices are different, in the case that one product has many embedded data types with the same price
|
# See of all prices are different, in the case that one product has many embedded data types with the same price
|
||||||
# One might have $121.95 and another 121.95 etc
|
# One might have $121.95 and another 121.95 etc
|
||||||
logger.warning(f"More than one price found {price_result}, throwing exception, cant use this plugin.")
|
logger.warning(f"More than one price found {prices_found}, throwing exception, cant use this plugin.")
|
||||||
raise MoreThanOnePriceFound()
|
raise MoreThanOnePriceFound()
|
||||||
|
|
||||||
value['price'] = price_result[0]
|
value['price'] = price_result[0].value
|
||||||
|
|
||||||
pricecurrency_result = pricecurrency_parse.find(data)
|
pricecurrency_result = pricecurrency_parse.find(data)
|
||||||
if pricecurrency_result:
|
if pricecurrency_result:
|
||||||
@@ -144,9 +118,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
screenshot = None
|
screenshot = None
|
||||||
xpath_data = None
|
xpath_data = None
|
||||||
|
|
||||||
def run_changedetection(self, watch):
|
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
||||||
import hashlib
|
|
||||||
|
|
||||||
if not watch:
|
if not watch:
|
||||||
raise Exception("Watch no longer exists.")
|
raise Exception("Watch no longer exists.")
|
||||||
|
|
||||||
@@ -160,20 +132,6 @@ class perform_site_check(difference_detection_processor):
|
|||||||
update_obj['content_type'] = self.fetcher.headers.get('Content-Type', '')
|
update_obj['content_type'] = self.fetcher.headers.get('Content-Type', '')
|
||||||
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
|
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
|
||||||
|
|
||||||
# Only try to process restock information (like scraping for keywords) if the page was actually rendered correctly.
|
|
||||||
# Otherwise it will assume "in stock" because nothing suggesting the opposite was found
|
|
||||||
from ...html_tools import html_to_text
|
|
||||||
text = html_to_text(self.fetcher.content)
|
|
||||||
logger.debug(f"Length of text after conversion: {len(text)}")
|
|
||||||
if not len(text):
|
|
||||||
from ...content_fetchers.exceptions import ReplyWithContentButNoText
|
|
||||||
raise ReplyWithContentButNoText(url=watch.link,
|
|
||||||
status_code=self.fetcher.get_last_status_code(),
|
|
||||||
screenshot=self.fetcher.screenshot,
|
|
||||||
html_content=self.fetcher.content,
|
|
||||||
xpath_data=self.fetcher.xpath_data
|
|
||||||
)
|
|
||||||
|
|
||||||
# Which restock settings to compare against?
|
# Which restock settings to compare against?
|
||||||
restock_settings = watch.get('restock_settings', {})
|
restock_settings = watch.get('restock_settings', {})
|
||||||
|
|
||||||
@@ -188,7 +146,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
|
|
||||||
itemprop_availability = {}
|
itemprop_availability = {}
|
||||||
try:
|
try:
|
||||||
itemprop_availability = get_itemprop_availability(self.fetcher.content)
|
itemprop_availability = get_itemprop_availability(html_content=self.fetcher.content)
|
||||||
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.",
|
||||||
@@ -224,7 +182,7 @@ class perform_site_check(difference_detection_processor):
|
|||||||
itemprop_availability['original_price'] = itemprop_availability.get('price')
|
itemprop_availability['original_price'] = itemprop_availability.get('price')
|
||||||
update_obj['restock']["original_price"] = itemprop_availability.get('price')
|
update_obj['restock']["original_price"] = itemprop_availability.get('price')
|
||||||
|
|
||||||
if not self.fetcher.instock_data and not itemprop_availability.get('availability') and not itemprop_availability.get('price'):
|
if not self.fetcher.instock_data and not itemprop_availability.get('availability'):
|
||||||
raise ProcessorException(
|
raise ProcessorException(
|
||||||
message=f"Unable to extract restock data for this page unfortunately. (Got code {self.fetcher.get_last_status_code()} from server), no embedded stock information was found and nothing interesting in the text, try using this watch with Chrome.",
|
message=f"Unable to extract restock data for this page unfortunately. (Got code {self.fetcher.get_last_status_code()} from server), no embedded stock information was found and nothing interesting in the text, try using this watch with Chrome.",
|
||||||
url=watch.get('url'),
|
url=watch.get('url'),
|
||||||
@@ -233,21 +191,12 @@ class perform_site_check(difference_detection_processor):
|
|||||||
xpath_data=self.fetcher.xpath_data
|
xpath_data=self.fetcher.xpath_data
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"self.fetcher.instock_data is - '{self.fetcher.instock_data}' and itemprop_availability.get('availability') is {itemprop_availability.get('availability')}")
|
|
||||||
# Nothing automatic in microdata found, revert to scraping the page
|
# Nothing automatic in microdata found, revert to scraping the page
|
||||||
if self.fetcher.instock_data and itemprop_availability.get('availability') is None:
|
if self.fetcher.instock_data and itemprop_availability.get('availability') is None:
|
||||||
# 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.
|
# 'Possibly in stock' comes from stock-not-in-stock.js when no string found above the fold.
|
||||||
# Careful! this does not really come from chrome/js when the watch is set to plaintext
|
# Careful! this does not really come from chrome/js when the watch is set to plaintext
|
||||||
update_obj['restock']["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False
|
update_obj['restock']["in_stock"] = True if self.fetcher.instock_data == 'Possibly in stock' else False
|
||||||
logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned instock_data - '{self.fetcher.instock_data}' from JS scraper.")
|
logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned '{self.fetcher.instock_data}' from JS scraper.")
|
||||||
|
|
||||||
# Very often websites will lie about the 'availability' in the metadata, so if the scraped version says its NOT in stock, use that.
|
|
||||||
if self.fetcher.instock_data and self.fetcher.instock_data != 'Possibly in stock':
|
|
||||||
if update_obj['restock'].get('in_stock'):
|
|
||||||
logger.warning(
|
|
||||||
f"Lie detected in the availability machine data!! when scraping said its not in stock!! itemprop was '{itemprop_availability}' and scraped from browser was '{self.fetcher.instock_data}' update obj was {update_obj['restock']} ")
|
|
||||||
logger.warning(f"Setting instock to FALSE, scraper found '{self.fetcher.instock_data}' in the body but metadata reported not-in-stock")
|
|
||||||
update_obj['restock']["in_stock"] = False
|
|
||||||
|
|
||||||
# What we store in the snapshot
|
# What we store in the snapshot
|
||||||
price = update_obj.get('restock').get('price') if update_obj.get('restock').get('price') else ""
|
price = update_obj.get('restock').get('price') if update_obj.get('restock').get('price') else ""
|
||||||
@@ -311,4 +260,4 @@ class perform_site_check(difference_detection_processor):
|
|||||||
# Always record the new checksum
|
# Always record the new checksum
|
||||||
update_obj["previous_md5"] = fetched_md5
|
update_obj["previous_md5"] = fetched_md5
|
||||||
|
|
||||||
return changed_detected, update_obj, snapshot_content.strip()
|
return changed_detected, update_obj, snapshot_content.encode('utf-8').strip()
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _task(watch, update_handler):
|
|
||||||
from changedetectionio.content_fetchers.exceptions import ReplyWithContentButNoText
|
|
||||||
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
|
|
||||||
|
|
||||||
text_after_filter = ''
|
|
||||||
|
|
||||||
try:
|
|
||||||
# The slow process (we run 2 of these in parallel)
|
|
||||||
changed_detected, update_obj, text_after_filter = update_handler.run_changedetection(watch=watch)
|
|
||||||
except FilterNotFoundInResponse as e:
|
|
||||||
text_after_filter = f"Filter not found in HTML: {str(e)}"
|
|
||||||
except ReplyWithContentButNoText as e:
|
|
||||||
text_after_filter = f"Filter found but no text (empty result)"
|
|
||||||
except Exception as e:
|
|
||||||
text_after_filter = f"Error: {str(e)}"
|
|
||||||
|
|
||||||
if not text_after_filter.strip():
|
|
||||||
text_after_filter = 'Empty content'
|
|
||||||
|
|
||||||
# because run_changedetection always returns bytes due to saving the snapshots etc
|
|
||||||
text_after_filter = text_after_filter.decode('utf-8') if isinstance(text_after_filter, bytes) else text_after_filter
|
|
||||||
|
|
||||||
return text_after_filter
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_filter_prevew(datastore, watch_uuid):
|
|
||||||
'''Used by @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])'''
|
|
||||||
from changedetectionio import forms, html_tools
|
|
||||||
from changedetectionio.model.Watch import model as watch_model
|
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
|
||||||
from copy import deepcopy
|
|
||||||
from flask import request, jsonify
|
|
||||||
import brotli
|
|
||||||
import importlib
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
text_after_filter = ''
|
|
||||||
text_before_filter = ''
|
|
||||||
trigger_line_numbers = []
|
|
||||||
ignore_line_numbers = []
|
|
||||||
|
|
||||||
tmp_watch = deepcopy(datastore.data['watching'].get(watch_uuid))
|
|
||||||
|
|
||||||
if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir):
|
|
||||||
# Splice in the temporary stuff from the form
|
|
||||||
form = forms.processor_text_json_diff_form(formdata=request.form if request.method == 'POST' else None,
|
|
||||||
data=request.form
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only update vars that came in via the AJAX post
|
|
||||||
p = {k: v for k, v in form.data.items() if k in request.form.keys()}
|
|
||||||
tmp_watch.update(p)
|
|
||||||
blank_watch_no_filters = watch_model()
|
|
||||||
blank_watch_no_filters['url'] = tmp_watch.get('url')
|
|
||||||
|
|
||||||
latest_filename = next(reversed(tmp_watch.history))
|
|
||||||
html_fname = os.path.join(tmp_watch.watch_data_dir, f"{latest_filename}.html.br")
|
|
||||||
with open(html_fname, 'rb') as f:
|
|
||||||
decompressed_data = brotli.decompress(f.read()).decode('utf-8') if html_fname.endswith('.br') else f.read().decode('utf-8')
|
|
||||||
|
|
||||||
# Just like a normal change detection except provide a fake "watch" object and dont call .call_browser()
|
|
||||||
processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
|
|
||||||
update_handler = processor_module.perform_site_check(datastore=datastore,
|
|
||||||
watch_uuid=tmp_watch.get('uuid') # probably not needed anymore anyway?
|
|
||||||
)
|
|
||||||
# Use the last loaded HTML as the input
|
|
||||||
update_handler.datastore = datastore
|
|
||||||
update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string
|
|
||||||
update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
|
|
||||||
|
|
||||||
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk
|
|
||||||
# Do this as a parallel process because it could take some time
|
|
||||||
with ProcessPoolExecutor(max_workers=2) as executor:
|
|
||||||
future1 = executor.submit(_task, tmp_watch, update_handler)
|
|
||||||
future2 = executor.submit(_task, blank_watch_no_filters, update_handler)
|
|
||||||
|
|
||||||
text_after_filter = future1.result()
|
|
||||||
text_before_filter = future2.result()
|
|
||||||
|
|
||||||
try:
|
|
||||||
trigger_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
|
|
||||||
wordlist=tmp_watch['trigger_text'],
|
|
||||||
mode='line numbers'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
text_before_filter = f"Error: {str(e)}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', [])
|
|
||||||
ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
|
|
||||||
wordlist=text_to_ignore,
|
|
||||||
mode='line numbers'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
text_before_filter = f"Error: {str(e)}"
|
|
||||||
|
|
||||||
logger.trace(f"Parsed in {time.time() - now:.3f}s")
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
'after_filter': text_after_filter,
|
|
||||||
'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
|
|
||||||
'duration': time.time() - now,
|
|
||||||
'trigger_line_numbers': trigger_line_numbers,
|
|
||||||
'ignore_line_numbers': ignore_line_numbers,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import re
|
|||||||
import urllib3
|
import urllib3
|
||||||
|
|
||||||
from changedetectionio.processors import difference_detection_processor
|
from changedetectionio.processors import difference_detection_processor
|
||||||
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
|
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text
|
||||||
from changedetectionio import html_tools, content_fetchers
|
from changedetectionio import html_tools, content_fetchers
|
||||||
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
|
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -35,7 +35,7 @@ class PDFToHTMLToolNotFound(ValueError):
|
|||||||
# (set_proxy_from_list)
|
# (set_proxy_from_list)
|
||||||
class perform_site_check(difference_detection_processor):
|
class perform_site_check(difference_detection_processor):
|
||||||
|
|
||||||
def run_changedetection(self, watch):
|
def run_changedetection(self, watch, skip_when_checksum_same=True):
|
||||||
changed_detected = False
|
changed_detected = False
|
||||||
html_content = ""
|
html_content = ""
|
||||||
screenshot = False # as bytes
|
screenshot = False # as bytes
|
||||||
@@ -58,6 +58,9 @@ class perform_site_check(difference_detection_processor):
|
|||||||
# Watches added automatically in the queue manager will skip if its the same checksum as the previous run
|
# Watches added automatically in the queue manager will skip if its the same checksum as the previous run
|
||||||
# Saves a lot of CPU
|
# Saves a lot of CPU
|
||||||
update_obj['previous_md5_before_filters'] = hashlib.md5(self.fetcher.content.encode('utf-8')).hexdigest()
|
update_obj['previous_md5_before_filters'] = hashlib.md5(self.fetcher.content.encode('utf-8')).hexdigest()
|
||||||
|
if skip_when_checksum_same:
|
||||||
|
if update_obj['previous_md5_before_filters'] == watch.get('previous_md5_before_filters'):
|
||||||
|
raise content_fetchers.exceptions.checksumFromPreviousCheckWasTheSame()
|
||||||
|
|
||||||
# Fetching complete, now filters
|
# Fetching complete, now filters
|
||||||
|
|
||||||
@@ -172,13 +175,13 @@ class perform_site_check(difference_detection_processor):
|
|||||||
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)
|
||||||
|
|
||||||
elif filter_rule.startswith('xpath1:'):
|
elif filter_rule.startswith('xpath1:'):
|
||||||
html_content += html_tools.xpath1_filter(xpath_filter=filter_rule.replace('xpath1:', ''),
|
html_content += 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)
|
||||||
else:
|
else:
|
||||||
|
# CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text
|
||||||
html_content += html_tools.include_filters(include_filters=filter_rule,
|
html_content += 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)
|
||||||
@@ -194,21 +197,25 @@ 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 = html_tools.html_to_text(html_content=html_content,
|
stripped_text_from_html = \
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
if watch.get('trim_text_whitespace'):
|
if watch.get('sort_text_alphabetically') and stripped_text_from_html:
|
||||||
stripped_text_from_html = '\n'.join(line.strip() for line in stripped_text_from_html.replace("\n\n", "\n").splitlines())
|
# Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap
|
||||||
|
# we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here.
|
||||||
|
stripped_text_from_html = stripped_text_from_html.replace('\n\n', '\n')
|
||||||
|
stripped_text_from_html = '\n'.join( sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower() ))
|
||||||
|
|
||||||
# Re #340 - return the content before the 'ignore text' was applied
|
# Re #340 - return the content before the 'ignore text' was applied
|
||||||
# Also used to calculate/show what was removed
|
text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8')
|
||||||
text_content_before_ignored_filter = stripped_text_from_html
|
|
||||||
|
|
||||||
# @todo whitespace coming from missing rtrim()?
|
# @todo whitespace coming from missing rtrim()?
|
||||||
# stripped_text_from_html could be based on their preferences, replace the processed text with only that which they want to know about.
|
# stripped_text_from_html could be based on their preferences, replace the processed text with only that which they want to know about.
|
||||||
# Rewrite's the processing text based on only what diff result they want to see
|
# Rewrite's the processing text based on only what diff result they want to see
|
||||||
|
|
||||||
if watch.has_special_diff_filter_options_set() and len(watch.history.keys()):
|
if watch.has_special_diff_filter_options_set() and len(watch.history.keys()):
|
||||||
# Now the content comes from the diff-parser and not the returned HTTP traffic, so could be some differences
|
# Now the content comes from the diff-parser and not the returned HTTP traffic, so could be some differences
|
||||||
from changedetectionio import diff
|
from changedetectionio import diff
|
||||||
@@ -223,12 +230,12 @@ class perform_site_check(difference_detection_processor):
|
|||||||
line_feed_sep="\n",
|
line_feed_sep="\n",
|
||||||
include_change_type_prefix=False)
|
include_change_type_prefix=False)
|
||||||
|
|
||||||
watch.save_last_text_fetched_before_filters(text_content_before_ignored_filter.encode('utf-8'))
|
watch.save_last_text_fetched_before_filters(text_content_before_ignored_filter)
|
||||||
|
|
||||||
if not rendered_diff and stripped_text_from_html:
|
if not rendered_diff and stripped_text_from_html:
|
||||||
# We had some content, but no differences were found
|
# We had some content, but no differences were found
|
||||||
# Store our new file as the MD5 so it will trigger in the future
|
# Store our new file as the MD5 so it will trigger in the future
|
||||||
c = hashlib.md5(stripped_text_from_html.translate(TRANSLATE_WHITESPACE_TABLE).encode('utf-8')).hexdigest()
|
c = hashlib.md5(text_content_before_ignored_filter.translate(None, b'\r\n\t ')).hexdigest()
|
||||||
return False, {'previous_md5': c}, stripped_text_from_html.encode('utf-8')
|
return False, {'previous_md5': c}, stripped_text_from_html.encode('utf-8')
|
||||||
else:
|
else:
|
||||||
stripped_text_from_html = rendered_diff
|
stripped_text_from_html = rendered_diff
|
||||||
@@ -249,6 +256,14 @@ class perform_site_check(difference_detection_processor):
|
|||||||
|
|
||||||
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
|
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
|
||||||
|
|
||||||
|
# If there's text to skip
|
||||||
|
# @todo we could abstract out the get_text() to handle this cleaner
|
||||||
|
text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', [])
|
||||||
|
if len(text_to_ignore):
|
||||||
|
stripped_text_from_html = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore)
|
||||||
|
else:
|
||||||
|
stripped_text_from_html = stripped_text_from_html.encode('utf8')
|
||||||
|
|
||||||
# 615 Extract text by regex
|
# 615 Extract text by regex
|
||||||
extract_text = watch.get('extract_text', [])
|
extract_text = watch.get('extract_text', [])
|
||||||
if len(extract_text) > 0:
|
if len(extract_text) > 0:
|
||||||
@@ -257,53 +272,37 @@ class perform_site_check(difference_detection_processor):
|
|||||||
# incase they specified something in '/.../x'
|
# incase they specified something in '/.../x'
|
||||||
if re.search(PERL_STYLE_REGEX, s_re, re.IGNORECASE):
|
if re.search(PERL_STYLE_REGEX, s_re, re.IGNORECASE):
|
||||||
regex = html_tools.perl_style_slash_enclosed_regex_to_options(s_re)
|
regex = html_tools.perl_style_slash_enclosed_regex_to_options(s_re)
|
||||||
result = re.findall(regex, stripped_text_from_html)
|
result = re.findall(regex.encode('utf-8'), stripped_text_from_html)
|
||||||
|
|
||||||
for l in result:
|
for l in result:
|
||||||
if type(l) is tuple:
|
if type(l) is tuple:
|
||||||
# @todo - some formatter option default (between groups)
|
# @todo - some formatter option default (between groups)
|
||||||
regex_matched_output += list(l) + ['\n']
|
regex_matched_output += list(l) + [b'\n']
|
||||||
else:
|
else:
|
||||||
# @todo - some formatter option default (between each ungrouped result)
|
# @todo - some formatter option default (between each ungrouped result)
|
||||||
regex_matched_output += [l] + ['\n']
|
regex_matched_output += [l] + [b'\n']
|
||||||
else:
|
else:
|
||||||
# Doesnt look like regex, just hunt for plaintext and return that which matches
|
# Doesnt look like regex, just hunt for plaintext and return that which matches
|
||||||
# `stripped_text_from_html` will be bytes, so we must encode s_re also to bytes
|
# `stripped_text_from_html` will be bytes, so we must encode s_re also to bytes
|
||||||
r = re.compile(re.escape(s_re), re.IGNORECASE)
|
r = re.compile(re.escape(s_re.encode('utf-8')), re.IGNORECASE)
|
||||||
res = r.findall(stripped_text_from_html)
|
res = r.findall(stripped_text_from_html)
|
||||||
if res:
|
if res:
|
||||||
for match in res:
|
for match in res:
|
||||||
regex_matched_output += [match] + ['\n']
|
regex_matched_output += [match] + [b'\n']
|
||||||
|
|
||||||
##########################################################
|
|
||||||
stripped_text_from_html = ''
|
|
||||||
|
|
||||||
|
# Now we will only show what the regex matched
|
||||||
|
stripped_text_from_html = b''
|
||||||
|
text_content_before_ignored_filter = b''
|
||||||
if regex_matched_output:
|
if regex_matched_output:
|
||||||
# @todo some formatter for presentation?
|
# @todo some formatter for presentation?
|
||||||
stripped_text_from_html = ''.join(regex_matched_output)
|
stripped_text_from_html = b''.join(regex_matched_output)
|
||||||
|
text_content_before_ignored_filter = stripped_text_from_html
|
||||||
if watch.get('remove_duplicate_lines'):
|
|
||||||
stripped_text_from_html = '\n'.join(dict.fromkeys(line for line in stripped_text_from_html.replace("\n\n", "\n").splitlines()))
|
|
||||||
|
|
||||||
|
|
||||||
if watch.get('sort_text_alphabetically'):
|
|
||||||
# Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap
|
|
||||||
# we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here.
|
|
||||||
stripped_text_from_html = stripped_text_from_html.replace("\n\n", "\n")
|
|
||||||
stripped_text_from_html = '\n'.join(sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower()))
|
|
||||||
|
|
||||||
### CALCULATE MD5
|
|
||||||
# If there's text to ignore
|
|
||||||
text_to_ignore = watch.get('ignore_text', []) + self.datastore.data['settings']['application'].get('global_ignore_text', [])
|
|
||||||
text_for_checksuming = stripped_text_from_html
|
|
||||||
if text_to_ignore:
|
|
||||||
text_for_checksuming = html_tools.strip_ignore_text(stripped_text_from_html, text_to_ignore)
|
|
||||||
|
|
||||||
# Re #133 - if we should strip whitespaces from triggering the change detected comparison
|
# Re #133 - if we should strip whitespaces from triggering the change detected comparison
|
||||||
if text_for_checksuming and self.datastore.data['settings']['application'].get('ignore_whitespace', False):
|
if self.datastore.data['settings']['application'].get('ignore_whitespace', False):
|
||||||
fetched_md5 = hashlib.md5(text_for_checksuming.translate(TRANSLATE_WHITESPACE_TABLE).encode('utf-8')).hexdigest()
|
fetched_md5 = hashlib.md5(stripped_text_from_html.translate(None, b'\r\n\t ')).hexdigest()
|
||||||
else:
|
else:
|
||||||
fetched_md5 = hashlib.md5(text_for_checksuming.encode('utf-8')).hexdigest()
|
fetched_md5 = hashlib.md5(stripped_text_from_html).hexdigest()
|
||||||
|
|
||||||
############ Blocking rules, after checksum #################
|
############ Blocking rules, after checksum #################
|
||||||
blocked = False
|
blocked = False
|
||||||
@@ -331,14 +330,25 @@ class perform_site_check(difference_detection_processor):
|
|||||||
if result:
|
if result:
|
||||||
blocked = True
|
blocked = True
|
||||||
|
|
||||||
|
# The main thing that all this at the moment comes down to :)
|
||||||
|
if watch.get('previous_md5') != fetched_md5:
|
||||||
|
changed_detected = True
|
||||||
|
|
||||||
# Looks like something changed, but did it match all the rules?
|
# Looks like something changed, but did it match all the rules?
|
||||||
if blocked:
|
if blocked:
|
||||||
changed_detected = False
|
changed_detected = False
|
||||||
|
|
||||||
|
logger.debug(f"Watch UUID {watch.get('uuid')} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}")
|
||||||
|
|
||||||
|
if changed_detected:
|
||||||
|
if watch.get('check_unique_lines', False):
|
||||||
|
has_unique_lines = watch.lines_contain_something_unique_compared_to_history(lines=stripped_text_from_html.splitlines())
|
||||||
|
# One or more lines? unsure?
|
||||||
|
if not has_unique_lines:
|
||||||
|
logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} didnt have anything new setting change_detected=False")
|
||||||
|
changed_detected = False
|
||||||
else:
|
else:
|
||||||
# The main thing that all this at the moment comes down to :)
|
logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} had unique content")
|
||||||
if watch.get('previous_md5') != fetched_md5:
|
|
||||||
changed_detected = True
|
|
||||||
|
|
||||||
# Always record the new checksum
|
# Always record the new checksum
|
||||||
update_obj["previous_md5"] = fetched_md5
|
update_obj["previous_md5"] = fetched_md5
|
||||||
@@ -347,24 +357,4 @@ class perform_site_check(difference_detection_processor):
|
|||||||
if not watch.get('previous_md5'):
|
if not watch.get('previous_md5'):
|
||||||
watch['previous_md5'] = fetched_md5
|
watch['previous_md5'] = fetched_md5
|
||||||
|
|
||||||
logger.debug(f"Watch UUID {watch.get('uuid')} content check - Previous MD5: {watch.get('previous_md5')}, Fetched MD5 {fetched_md5}")
|
return changed_detected, update_obj, text_content_before_ignored_filter
|
||||||
|
|
||||||
if changed_detected:
|
|
||||||
if watch.get('check_unique_lines', False):
|
|
||||||
ignore_whitespace = self.datastore.data['settings']['application'].get('ignore_whitespace')
|
|
||||||
|
|
||||||
has_unique_lines = watch.lines_contain_something_unique_compared_to_history(
|
|
||||||
lines=stripped_text_from_html.splitlines(),
|
|
||||||
ignore_whitespace=ignore_whitespace
|
|
||||||
)
|
|
||||||
|
|
||||||
# One or more lines? unsure?
|
|
||||||
if not has_unique_lines:
|
|
||||||
logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} didnt have anything new setting change_detected=False")
|
|
||||||
changed_detected = False
|
|
||||||
else:
|
|
||||||
logger.debug(f"check_unique_lines: UUID {watch.get('uuid')} had unique content")
|
|
||||||
|
|
||||||
|
|
||||||
# stripped_text_from_html - Everything after filters and NO 'ignored' content
|
|
||||||
return changed_detected, update_obj, stripped_text_from_html
|
|
||||||
|
|||||||
@@ -16,31 +16,25 @@ echo "---------------------------------- SOCKS5 -------------------"
|
|||||||
docker run --network changedet-network \
|
docker run --network changedet-network \
|
||||||
-v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \
|
-v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \
|
||||||
--rm \
|
--rm \
|
||||||
-e "FLASK_SERVER_NAME=cdio" \
|
|
||||||
--hostname cdio \
|
|
||||||
-e "SOCKSTEST=proxiesjson" \
|
-e "SOCKSTEST=proxiesjson" \
|
||||||
test-changedetectionio \
|
test-changedetectionio \
|
||||||
bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004 -s tests/proxy_socks5/test_socks5_proxy_sources.py'
|
bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy_sources.py'
|
||||||
|
|
||||||
# SOCKS5 related - by manually entering in UI
|
# SOCKS5 related - by manually entering in UI
|
||||||
docker run --network changedet-network \
|
docker run --network changedet-network \
|
||||||
--rm \
|
--rm \
|
||||||
-e "FLASK_SERVER_NAME=cdio" \
|
|
||||||
--hostname cdio \
|
|
||||||
-e "SOCKSTEST=manual" \
|
-e "SOCKSTEST=manual" \
|
||||||
test-changedetectionio \
|
test-changedetectionio \
|
||||||
bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004 -s tests/proxy_socks5/test_socks5_proxy.py'
|
bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy.py'
|
||||||
|
|
||||||
# SOCKS5 related - test from proxies.json via playwright - NOTE- PLAYWRIGHT DOESNT SUPPORT AUTHENTICATING PROXY
|
# SOCKS5 related - test from proxies.json via playwright - NOTE- PLAYWRIGHT DOESNT SUPPORT AUTHENTICATING PROXY
|
||||||
docker run --network changedet-network \
|
docker run --network changedet-network \
|
||||||
-e "SOCKSTEST=manual-playwright" \
|
-e "SOCKSTEST=manual-playwright" \
|
||||||
--hostname cdio \
|
|
||||||
-e "FLASK_SERVER_NAME=cdio" \
|
|
||||||
-v `pwd`/tests/proxy_socks5/proxies.json-example-noauth:/app/changedetectionio/test-datastore/proxies.json \
|
-v `pwd`/tests/proxy_socks5/proxies.json-example-noauth:/app/changedetectionio/test-datastore/proxies.json \
|
||||||
-e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" \
|
-e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" \
|
||||||
--rm \
|
--rm \
|
||||||
test-changedetectionio \
|
test-changedetectionio \
|
||||||
bash -c 'cd changedetectionio && pytest --live-server-host=0.0.0.0 --live-server-port=5004 -s tests/proxy_socks5/test_socks5_proxy_sources.py'
|
bash -c 'cd changedetectionio && pytest tests/proxy_socks5/test_socks5_proxy_sources.py'
|
||||||
|
|
||||||
echo "socks5 server logs"
|
echo "socks5 server logs"
|
||||||
docker logs socks5proxy
|
docker logs socks5proxy
|
||||||
|
|||||||
@@ -18,11 +18,9 @@ $(document).ready(function () {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".toggle-show").click(function (e) {
|
$("#notification-token-toggle").click(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let target = $(this).data('target');
|
$('#notification-tokens-info').toggle();
|
||||||
$(target).toggle();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
56
changedetectionio/static/js/limit.js
Normal file
56
changedetectionio/static/js/limit.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* debounce
|
||||||
|
* @param {integer} milliseconds This param indicates the number of milliseconds
|
||||||
|
* to wait after the last call before calling the original function.
|
||||||
|
* @param {object} What "this" refers to in the returned function.
|
||||||
|
* @return {function} This returns a function that when called will wait the
|
||||||
|
* indicated number of milliseconds after the last call before
|
||||||
|
* calling the original function.
|
||||||
|
*/
|
||||||
|
Function.prototype.debounce = function (milliseconds, context) {
|
||||||
|
var baseFunction = this,
|
||||||
|
timer = null,
|
||||||
|
wait = milliseconds;
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
var self = context || this,
|
||||||
|
args = arguments;
|
||||||
|
|
||||||
|
function complete() {
|
||||||
|
baseFunction.apply(self, args);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(complete, wait);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* throttle
|
||||||
|
* @param {integer} milliseconds This param indicates the number of milliseconds
|
||||||
|
* to wait between calls before calling the original function.
|
||||||
|
* @param {object} What "this" refers to in the returned function.
|
||||||
|
* @return {function} This returns a function that when called will wait the
|
||||||
|
* indicated number of milliseconds between calls before
|
||||||
|
* calling the original function.
|
||||||
|
*/
|
||||||
|
Function.prototype.throttle = function (milliseconds, context) {
|
||||||
|
var baseFunction = this,
|
||||||
|
lastEventTimestamp = null,
|
||||||
|
limit = milliseconds;
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
var self = context || this,
|
||||||
|
args = arguments,
|
||||||
|
now = Date.now();
|
||||||
|
|
||||||
|
if (!lastEventTimestamp || now - lastEventTimestamp >= limit) {
|
||||||
|
lastEventTimestamp = now;
|
||||||
|
baseFunction.apply(self, args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
(function ($) {
|
|
||||||
/**
|
|
||||||
* debounce
|
|
||||||
* @param {integer} milliseconds This param indicates the number of milliseconds
|
|
||||||
* to wait after the last call before calling the original function.
|
|
||||||
* @param {object} What "this" refers to in the returned function.
|
|
||||||
* @return {function} This returns a function that when called will wait the
|
|
||||||
* indicated number of milliseconds after the last call before
|
|
||||||
* calling the original function.
|
|
||||||
*/
|
|
||||||
Function.prototype.debounce = function (milliseconds, context) {
|
|
||||||
var baseFunction = this,
|
|
||||||
timer = null,
|
|
||||||
wait = milliseconds;
|
|
||||||
|
|
||||||
return function () {
|
|
||||||
var self = context || this,
|
|
||||||
args = arguments;
|
|
||||||
|
|
||||||
function complete() {
|
|
||||||
baseFunction.apply(self, args);
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
timer = setTimeout(complete, wait);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* throttle
|
|
||||||
* @param {integer} milliseconds This param indicates the number of milliseconds
|
|
||||||
* to wait between calls before calling the original function.
|
|
||||||
* @param {object} What "this" refers to in the returned function.
|
|
||||||
* @return {function} This returns a function that when called will wait the
|
|
||||||
* indicated number of milliseconds between calls before
|
|
||||||
* calling the original function.
|
|
||||||
*/
|
|
||||||
Function.prototype.throttle = function (milliseconds, context) {
|
|
||||||
var baseFunction = this,
|
|
||||||
lastEventTimestamp = null,
|
|
||||||
limit = milliseconds;
|
|
||||||
|
|
||||||
return function () {
|
|
||||||
var self = context || this,
|
|
||||||
args = arguments,
|
|
||||||
now = Date.now();
|
|
||||||
|
|
||||||
if (!lastEventTimestamp || now - lastEventTimestamp >= limit) {
|
|
||||||
lastEventTimestamp = now;
|
|
||||||
baseFunction.apply(self, args);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.highlightLines = function (configurations) {
|
|
||||||
return this.each(function () {
|
|
||||||
const $pre = $(this);
|
|
||||||
const textContent = $pre.text();
|
|
||||||
const lines = textContent.split(/\r?\n/); // Handles both \n and \r\n line endings
|
|
||||||
|
|
||||||
// Build a map of line numbers to styles
|
|
||||||
const lineStyles = {};
|
|
||||||
|
|
||||||
configurations.forEach(config => {
|
|
||||||
const {color, lines: lineNumbers} = config;
|
|
||||||
lineNumbers.forEach(lineNumber => {
|
|
||||||
lineStyles[lineNumber] = color;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to escape HTML characters
|
|
||||||
function escapeHtml(text) {
|
|
||||||
return text.replace(/[&<>"'`=\/]/g, function (s) {
|
|
||||||
return "&#" + s.charCodeAt(0) + ";";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each line
|
|
||||||
const processedLines = lines.map((line, index) => {
|
|
||||||
const lineNumber = index + 1; // Line numbers start at 1
|
|
||||||
const escapedLine = escapeHtml(line);
|
|
||||||
const color = lineStyles[lineNumber];
|
|
||||||
|
|
||||||
if (color) {
|
|
||||||
// Wrap the line in a span with inline style
|
|
||||||
return `<span style="background-color: ${color}">${escapedLine}</span>`;
|
|
||||||
} else {
|
|
||||||
return escapedLine;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Join the lines back together
|
|
||||||
const newContent = processedLines.join('\n');
|
|
||||||
|
|
||||||
// Set the new content as HTML
|
|
||||||
$pre.html(newContent);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
$.fn.miniTabs = function (tabsConfig, options) {
|
|
||||||
const settings = {
|
|
||||||
tabClass: 'minitab',
|
|
||||||
tabsContainerClass: 'minitabs',
|
|
||||||
activeClass: 'active',
|
|
||||||
...(options || {})
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.each(function () {
|
|
||||||
const $wrapper = $(this);
|
|
||||||
const $contents = $wrapper.find('div[id]').hide();
|
|
||||||
const $tabsContainer = $('<div>', {class: settings.tabsContainerClass}).prependTo($wrapper);
|
|
||||||
|
|
||||||
// Generate tabs
|
|
||||||
Object.entries(tabsConfig).forEach(([tabTitle, contentSelector], index) => {
|
|
||||||
const $content = $wrapper.find(contentSelector);
|
|
||||||
if (index === 0) $content.show(); // Show first content by default
|
|
||||||
|
|
||||||
$('<a>', {
|
|
||||||
class: `${settings.tabClass}${index === 0 ? ` ${settings.activeClass}` : ''}`,
|
|
||||||
text: tabTitle,
|
|
||||||
'data-target': contentSelector
|
|
||||||
}).appendTo($tabsContainer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tab click event
|
|
||||||
$tabsContainer.on('click', `.${settings.tabClass}`, function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const $tab = $(this);
|
|
||||||
const target = $tab.data('target');
|
|
||||||
|
|
||||||
// Update active tab
|
|
||||||
$tabsContainer.find(`.${settings.tabClass}`).removeClass(settings.activeClass);
|
|
||||||
$tab.addClass(settings.activeClass);
|
|
||||||
|
|
||||||
// Show/hide content
|
|
||||||
$contents.hide();
|
|
||||||
$wrapper.find(target).show();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Object to store ongoing requests by namespace
|
|
||||||
const requests = {};
|
|
||||||
|
|
||||||
$.abortiveSingularAjax = function (options) {
|
|
||||||
const namespace = options.namespace || 'default';
|
|
||||||
|
|
||||||
// Abort the current request in this namespace if it's still ongoing
|
|
||||||
if (requests[namespace]) {
|
|
||||||
requests[namespace].abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start a new AJAX request and store its reference in the correct namespace
|
|
||||||
requests[namespace] = $.ajax(options);
|
|
||||||
|
|
||||||
// Return the current request in case it's needed
|
|
||||||
return requests[namespace];
|
|
||||||
};
|
|
||||||
})(jQuery);
|
|
||||||
@@ -1,63 +1,53 @@
|
|||||||
function redirectToVersion(version) {
|
function redirect_to_version(version) {
|
||||||
var currentUrl = window.location.href.split('?')[0]; // Base URL without query parameters
|
var currentUrl = window.location.href;
|
||||||
|
var baseUrl = currentUrl.split('?')[0]; // Base URL without query parameters
|
||||||
var anchor = '';
|
var anchor = '';
|
||||||
|
|
||||||
// Check if there is an anchor
|
// Check if there is an anchor
|
||||||
if (currentUrl.indexOf('#') !== -1) {
|
if (baseUrl.indexOf('#') !== -1) {
|
||||||
anchor = currentUrl.substring(currentUrl.indexOf('#'));
|
anchor = baseUrl.substring(baseUrl.indexOf('#'));
|
||||||
currentUrl = currentUrl.substring(0, currentUrl.indexOf('#'));
|
baseUrl = baseUrl.substring(0, baseUrl.indexOf('#'));
|
||||||
}
|
}
|
||||||
|
window.location.href = baseUrl + '?version=' + version + anchor;
|
||||||
window.location.href = currentUrl + '?version=' + version + anchor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDateWidget() {
|
document.addEventListener('keydown', function (event) {
|
||||||
$(document).on('keydown', function (event) {
|
var selectElement = document.getElementById('preview-version');
|
||||||
var $selectElement = $('#preview-version');
|
if (selectElement) {
|
||||||
var $selectedOption = $selectElement.find('option:selected');
|
var selectedOption = selectElement.querySelector('option:checked');
|
||||||
|
if (selectedOption) {
|
||||||
if ($selectedOption.length) {
|
if (event.key === 'ArrowLeft') {
|
||||||
if (event.key === 'ArrowLeft' && $selectedOption.prev().length) {
|
if (selectedOption.previousElementSibling) {
|
||||||
redirectToVersion($selectedOption.prev().val());
|
redirect_to_version(selectedOption.previousElementSibling.value);
|
||||||
} else if (event.key === 'ArrowRight' && $selectedOption.next().length) {
|
}
|
||||||
redirectToVersion($selectedOption.next().val());
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
if (selectedOption.nextElementSibling) {
|
||||||
|
redirect_to_version(selectedOption.nextElementSibling.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
$('#preview-version').on('change', function () {
|
|
||||||
redirectToVersion($(this).val());
|
|
||||||
});
|
|
||||||
|
|
||||||
var $selectedOption = $('#preview-version option:selected');
|
|
||||||
|
|
||||||
if ($selectedOption.length) {
|
|
||||||
var $prevOption = $selectedOption.prev();
|
|
||||||
var $nextOption = $selectedOption.next();
|
|
||||||
|
|
||||||
if ($prevOption.length) {
|
|
||||||
$('#btn-previous').attr('href', '?version=' + $prevOption.val());
|
|
||||||
} else {
|
|
||||||
$('#btn-previous').remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($nextOption.length) {
|
|
||||||
$('#btn-next').attr('href', '?version=' + $nextOption.val());
|
|
||||||
} else {
|
|
||||||
$('#btn-next').remove();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function () {
|
|
||||||
if ($('#preview-version').length) {
|
|
||||||
setupDateWidget();
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#diff-col > pre').highlightLines([
|
|
||||||
{
|
|
||||||
'color': '#ee0000',
|
|
||||||
'lines': triggered_line_numbers
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
document.getElementById('preview-version').addEventListener('change', function () {
|
||||||
|
redirect_to_version(this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
var selectElement = document.getElementById('preview-version');
|
||||||
|
if (selectElement) {
|
||||||
|
var selectedOption = selectElement.querySelector('option:checked');
|
||||||
|
if (selectedOption) {
|
||||||
|
if (selectedOption.previousElementSibling) {
|
||||||
|
document.getElementById('btn-previous').href = "?version=" + selectedOption.previousElementSibling.value;
|
||||||
|
} else {
|
||||||
|
document.getElementById('btn-previous').remove()
|
||||||
|
}
|
||||||
|
if (selectedOption.nextElementSibling) {
|
||||||
|
document.getElementById('btn-next').href = "?version=" + selectedOption.nextElementSibling.value;
|
||||||
|
} else {
|
||||||
|
document.getElementById('btn-next').remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
$(function () {
|
$(function () {
|
||||||
/* add container before each proxy location to show status */
|
/* add container before each proxy location to show status */
|
||||||
var isActive = false;
|
|
||||||
|
|
||||||
function setup_html_widget() {
|
var option_li = $('.fetch-backend-proxy li').filter(function() {
|
||||||
var option_li = $('.fetch-backend-proxy li').filter(function () {
|
return $("input",this)[0].value.length >0;
|
||||||
return $("input", this)[0].value.length > 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//var option_li = $('.fetch-backend-proxy li');
|
||||||
|
var isActive = false;
|
||||||
$(option_li).prepend('<div class="proxy-status"></div>');
|
$(option_li).prepend('<div class="proxy-status"></div>');
|
||||||
$(option_li).append('<div class="proxy-timing"></div><div class="proxy-check-details"></div>');
|
$(option_li).append('<div class="proxy-timing"></div><div class="proxy-check-details"></div>');
|
||||||
}
|
|
||||||
|
|
||||||
function set_proxy_check_status(proxy_key, state) {
|
function set_proxy_check_status(proxy_key, state) {
|
||||||
// select input by value name
|
// select input by value name
|
||||||
@@ -59,14 +59,8 @@ $(function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('#check-all-proxies').click(function (e) {
|
$('#check-all-proxies').click(function (e) {
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!$('body').hasClass('proxy-check-active')) {
|
|
||||||
setup_html_widget();
|
|
||||||
$('body').addClass('proxy-check-active');
|
$('body').addClass('proxy-check-active');
|
||||||
}
|
|
||||||
|
|
||||||
$('.proxy-check-details').html('');
|
$('.proxy-check-details').html('');
|
||||||
$('.proxy-status').html('<span class="spinner"></span>').fadeIn();
|
$('.proxy-status').html('<span class="spinner"></span>').fadeIn();
|
||||||
$('.proxy-timing').html('');
|
$('.proxy-timing').html('');
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ function set_active_tab() {
|
|||||||
if (tab.length) {
|
if (tab.length) {
|
||||||
tab[0].parentElement.className = "active";
|
tab[0].parentElement.className = "active";
|
||||||
}
|
}
|
||||||
|
// hash could move the page down
|
||||||
|
window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function focus_error_tab() {
|
function focus_error_tab() {
|
||||||
|
|||||||
@@ -49,9 +49,4 @@ $(document).ready(function () {
|
|||||||
$("#overlay").toggleClass('visible');
|
$("#overlay").toggleClass('visible');
|
||||||
heartpath.style.fill = document.getElementById("overlay").classList.contains("visible") ? '#ff0000' : 'var(--color-background)';
|
heartpath.style.fill = document.getElementById("overlay").classList.contains("visible") ? '#ff0000' : 'var(--color-background)';
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(function () {
|
|
||||||
$('body').toggleClass('spinner-active', $.active > 0);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,51 +12,6 @@ function toggleOpacity(checkboxSelector, fieldSelector, inverted) {
|
|||||||
checkbox.addEventListener('change', updateOpacity);
|
checkbox.addEventListener('change', updateOpacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function request_textpreview_update() {
|
|
||||||
if (!$('body').hasClass('preview-text-enabled')) {
|
|
||||||
console.error("Preview text was requested but body tag was not setup")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {};
|
|
||||||
$('textarea:visible, input:visible').each(function () {
|
|
||||||
const $element = $(this); // Cache the jQuery object for the current element
|
|
||||||
const name = $element.attr('name'); // Get the name attribute of the element
|
|
||||||
data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('body').toggleClass('spinner-active', 1);
|
|
||||||
|
|
||||||
$.abortiveSingularAjax({
|
|
||||||
type: "POST",
|
|
||||||
url: preview_text_edit_filters_url,
|
|
||||||
data: data,
|
|
||||||
namespace: 'watchEdit'
|
|
||||||
}).done(function (data) {
|
|
||||||
console.debug(data['duration'])
|
|
||||||
$('#filters-and-triggers #text-preview-before-inner').text(data['before_filter']);
|
|
||||||
$('#filters-and-triggers #text-preview-inner')
|
|
||||||
.text(data['after_filter'])
|
|
||||||
.highlightLines([
|
|
||||||
{
|
|
||||||
'color': '#ee0000',
|
|
||||||
'lines': data['trigger_line_numbers']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'color': '#757575',
|
|
||||||
'lines': data['ignore_line_numbers']
|
|
||||||
}
|
|
||||||
])
|
|
||||||
}).fail(function (error) {
|
|
||||||
if (error.statusText === 'abort') {
|
|
||||||
console.log('Request was aborted due to a new request being fired.');
|
|
||||||
} else {
|
|
||||||
$('#filters-and-triggers #text-preview-inner').text('There was an error communicating with the server.');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$('#notification-setting-reset-to-default').click(function (e) {
|
$('#notification-setting-reset-to-default').click(function (e) {
|
||||||
$('#notification_title').val('');
|
$('#notification_title').val('');
|
||||||
@@ -72,21 +27,5 @@ $(document).ready(function () {
|
|||||||
|
|
||||||
toggleOpacity('#time_between_check_use_default', '#time_between_check', false);
|
toggleOpacity('#time_between_check_use_default', '#time_between_check', false);
|
||||||
|
|
||||||
const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
|
|
||||||
$("#text-preview-inner").css('max-height', (vh-300)+"px");
|
|
||||||
$("#text-preview-before-inner").css('max-height', (vh-300)+"px");
|
|
||||||
|
|
||||||
$("#activate-text-preview").click(function (e) {
|
|
||||||
$('body').toggleClass('preview-text-enabled')
|
|
||||||
request_textpreview_update();
|
|
||||||
const method = $('body').hasClass('preview-text-enabled') ? 'on' : 'off';
|
|
||||||
$('#filters-and-triggers textarea')[method]('blur', request_textpreview_update.throttle(1000));
|
|
||||||
$('#filters-and-triggers input')[method]('change', request_textpreview_update.throttle(1000));
|
|
||||||
$("#filters-and-triggers-tab")[method]('click', request_textpreview_update.throttle(1000));
|
|
||||||
});
|
|
||||||
$('.minitabs-wrapper').miniTabs({
|
|
||||||
"Content after filters": "#text-preview-inner",
|
|
||||||
"Content raw/before filters": "#text-preview-before-inner"
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,29 +40,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#browser-steps-fieldlist {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
#browser-steps .flex-wrapper {
|
#browser-steps .flex-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row;
|
flex-flow: row;
|
||||||
height: 70vh;
|
height: 70vh;
|
||||||
font-size: 80%;
|
|
||||||
#browser-steps-ui {
|
|
||||||
flex-grow: 1; /* Allow it to grow and fill the available space */
|
|
||||||
flex-shrink: 1; /* Allow it to shrink if needed */
|
|
||||||
flex-basis: 0; /* Start with 0 base width so it stretches as much as possible */
|
|
||||||
background-color: #eee;
|
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#browser-steps-fieldlist {
|
|
||||||
flex-grow: 0; /* Don't allow it to grow */
|
|
||||||
flex-shrink: 0; /* Don't allow it to shrink */
|
|
||||||
flex-basis: auto; /* Base width is determined by the content */
|
|
||||||
max-width: 400px; /* Set a max width to prevent overflow */
|
|
||||||
padding-left: 1rem;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* this is duplicate :( */
|
/* this is duplicate :( */
|
||||||
|
|||||||
@@ -25,19 +25,15 @@ ul#requests-extra_proxies {
|
|||||||
|
|
||||||
body.proxy-check-active {
|
body.proxy-check-active {
|
||||||
#request {
|
#request {
|
||||||
// Padding set by flex layout
|
|
||||||
/*
|
|
||||||
.proxy-status {
|
.proxy-status {
|
||||||
width: 2em;
|
width: 2em;
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
.proxy-check-details {
|
.proxy-check-details {
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
color: #555;
|
color: #555;
|
||||||
display: block;
|
display: block;
|
||||||
padding-left: 2em;
|
padding-left: 4em;
|
||||||
max-width: 500px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.proxy-timing {
|
.proxy-timing {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
.minitabs-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
> div[id] {
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.minitabs-content {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
> div {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: scroll;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.minitabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.minitab {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #333;
|
|
||||||
background-color: #f1f1f1;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-bottom: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.minitab:hover {
|
|
||||||
background-color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.minitab.active {
|
|
||||||
background-color: #fff;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
@import "minitabs";
|
|
||||||
|
|
||||||
body.preview-text-enabled {
|
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
|
||||||
#filters-and-triggers > div {
|
|
||||||
display: flex; /* Establishes Flexbox layout */
|
|
||||||
gap: 20px; /* Adds space between the columns */
|
|
||||||
position: relative; /* Ensures the sticky positioning is relative to this parent */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* layout of the page */
|
|
||||||
#edit-text-filter, #text-preview {
|
|
||||||
flex: 1; /* Each column takes an equal amount of available space */
|
|
||||||
align-self: flex-start; /* Aligns the right column to the start, allowing it to maintain its content height */
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-text-filter {
|
|
||||||
#pro-tips {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#text-preview {
|
|
||||||
position: sticky;
|
|
||||||
top: 20px;
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#activate-text-preview {
|
|
||||||
background-color: var(--color-grey-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* actual preview area */
|
|
||||||
.monospace-preview {
|
|
||||||
background: var(--color-background-input);
|
|
||||||
border: 1px solid var(--color-grey-600);
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--color-text-input);
|
|
||||||
font-family: "Courier New", Courier, monospace; /* Sets the font to a monospace type */
|
|
||||||
font-size: 70%;
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: pre-wrap; /* Preserves whitespace and line breaks like <pre> */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#activate-text-preview {
|
|
||||||
right: 0;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 3;
|
|
||||||
box-shadow: 1px 1px 4px var(--color-shadow-jump);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
@import "parts/_darkmode";
|
@import "parts/_darkmode";
|
||||||
@import "parts/_menu";
|
@import "parts/_menu";
|
||||||
@import "parts/_love";
|
@import "parts/_love";
|
||||||
@import "parts/preview_text_filter";
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -106,34 +105,10 @@ button.toggle-button {
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
border-bottom: 2px solid var(--color-menu-accent);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pure-menu-horizontal-spinner {
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
width: 100%;
|
|
||||||
animation: gradient 200s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.spinner-active {
|
|
||||||
#pure-menu-horizontal-spinner {
|
|
||||||
animation: gradient 1s ease infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gradient {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pure-menu-heading {
|
.pure-menu-heading {
|
||||||
color: var(--color-text-menu-heading);
|
color: var(--color-text-menu-heading);
|
||||||
}
|
}
|
||||||
@@ -147,14 +122,8 @@ body.spinner-active {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.tab-pane-inner {
|
|
||||||
// .tab-pane-inner will have the #id that the tab button jumps/anchors to
|
|
||||||
scroll-margin-top: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
section.content {
|
section.content {
|
||||||
padding-top: 100px;
|
padding-top: 5em;
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -351,6 +320,10 @@ a.pure-button-selected {
|
|||||||
background: var(--color-background-button-cancel);
|
background: var(--color-background-button-cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#save_button {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.messages {
|
.messages {
|
||||||
li {
|
li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -647,9 +620,9 @@ footer {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
display: flex;
|
>* {
|
||||||
align-items: center;
|
display: inline-block;
|
||||||
gap: 1em;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,12 +682,6 @@ footer {
|
|||||||
tr {
|
tr {
|
||||||
th {
|
th {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
// Hide the "Last" text for smaller screens
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.hide-on-mobile {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.empty-cell {
|
.empty-cell {
|
||||||
@@ -730,24 +697,6 @@ footer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody {
|
|
||||||
tr {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
// The third child of each row will take up the remaining space
|
|
||||||
// This is useful for the URL column, which should expand to fill the remaining space
|
|
||||||
:nth-child(3) {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
// The last three children (from the end) of each row will take up the full width
|
|
||||||
// This is useful for the "Last Checked", "Last Changed", and the action buttons columns, which should each take up the full width
|
|
||||||
:nth-last-child(-n+3) {
|
|
||||||
flex-basis: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-checked {
|
.last-checked {
|
||||||
>span {
|
>span {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
@@ -866,11 +815,6 @@ textarea::placeholder {
|
|||||||
- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out
|
- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out
|
||||||
- Rely always on width in CSS
|
- Rely always on width in CSS
|
||||||
*/
|
*/
|
||||||
/** Set max width for input field */
|
|
||||||
.m-d {
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (min-width: 761px) {
|
@media only screen and (min-width: 761px) {
|
||||||
|
|
||||||
/* m-d is medium-desktop */
|
/* m-d is medium-desktop */
|
||||||
@@ -937,7 +881,6 @@ $form-edge-padding: 20px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-pane-inner {
|
.tab-pane-inner {
|
||||||
|
|
||||||
&:not(:target) {
|
&:not(:target) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -987,13 +930,6 @@ body.full-width {
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make action buttons have consistent size and spacing */
|
|
||||||
#actions .pure-control-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.625em;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pure-form-message-inline {
|
.pure-form-message-inline {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
color: var(--color-text-input-description);
|
color: var(--color-text-input-description);
|
||||||
@@ -1037,28 +973,6 @@ ul {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 760px) {
|
|
||||||
.time-check-widget {
|
|
||||||
tbody {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr auto 1fr;
|
|
||||||
gap: 0.625em 0.3125em;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
tr {
|
|
||||||
display: contents;
|
|
||||||
th {
|
|
||||||
text-align: right;
|
|
||||||
padding-right: 5px;
|
|
||||||
}
|
|
||||||
input[type="number"] {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@import "parts/_visualselector";
|
@import "parts/_visualselector";
|
||||||
|
|
||||||
#webdriver_delay {
|
#webdriver_delay {
|
||||||
|
|||||||
@@ -46,31 +46,14 @@
|
|||||||
#browser_steps li > label {
|
#browser_steps li > label {
|
||||||
display: none; }
|
display: none; }
|
||||||
|
|
||||||
|
#browser-steps-fieldlist {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll; }
|
||||||
|
|
||||||
#browser-steps .flex-wrapper {
|
#browser-steps .flex-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row;
|
flex-flow: row;
|
||||||
height: 70vh;
|
height: 70vh; }
|
||||||
font-size: 80%; }
|
|
||||||
#browser-steps .flex-wrapper #browser-steps-ui {
|
|
||||||
flex-grow: 1;
|
|
||||||
/* Allow it to grow and fill the available space */
|
|
||||||
flex-shrink: 1;
|
|
||||||
/* Allow it to shrink if needed */
|
|
||||||
flex-basis: 0;
|
|
||||||
/* Start with 0 base width so it stretches as much as possible */
|
|
||||||
background-color: #eee;
|
|
||||||
border-radius: 5px; }
|
|
||||||
#browser-steps .flex-wrapper #browser-steps-fieldlist {
|
|
||||||
flex-grow: 0;
|
|
||||||
/* Don't allow it to grow */
|
|
||||||
flex-shrink: 0;
|
|
||||||
/* Don't allow it to shrink */
|
|
||||||
flex-basis: auto;
|
|
||||||
/* Base width is determined by the content */
|
|
||||||
max-width: 400px;
|
|
||||||
/* Set a max width to prevent overflow */
|
|
||||||
padding-left: 1rem;
|
|
||||||
overflow-y: scroll; }
|
|
||||||
|
|
||||||
/* this is duplicate :( */
|
/* this is duplicate :( */
|
||||||
#browsersteps-selector-wrapper {
|
#browsersteps-selector-wrapper {
|
||||||
@@ -119,19 +102,16 @@ ul#requests-extra_proxies {
|
|||||||
#request label[for=proxy] {
|
#request label[for=proxy] {
|
||||||
display: inline-block; }
|
display: inline-block; }
|
||||||
|
|
||||||
body.proxy-check-active #request {
|
body.proxy-check-active #request .proxy-status {
|
||||||
/*
|
width: 2em; }
|
||||||
.proxy-status {
|
|
||||||
width: 2em;
|
body.proxy-check-active #request .proxy-check-details {
|
||||||
}
|
|
||||||
*/ }
|
|
||||||
body.proxy-check-active #request .proxy-check-details {
|
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
color: #555;
|
color: #555;
|
||||||
display: block;
|
display: block;
|
||||||
padding-left: 2em;
|
padding-left: 4em; }
|
||||||
max-width: 500px; }
|
|
||||||
body.proxy-check-active #request .proxy-timing {
|
body.proxy-check-active #request .proxy-timing {
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
color: var(--color-link); }
|
color: var(--color-link); }
|
||||||
@@ -431,83 +411,6 @@ html[data-darkmode="true"] #toggle-light-mode .icon-dark {
|
|||||||
fill: #ff0000 !important;
|
fill: #ff0000 !important;
|
||||||
transition: all ease 0.3s !important; }
|
transition: all ease 0.3s !important; }
|
||||||
|
|
||||||
.minitabs-wrapper {
|
|
||||||
width: 100%; }
|
|
||||||
.minitabs-wrapper > div[id] {
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-top: none; }
|
|
||||||
.minitabs-wrapper .minitabs-content {
|
|
||||||
width: 100%;
|
|
||||||
display: flex; }
|
|
||||||
.minitabs-wrapper .minitabs-content > div {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: scroll; }
|
|
||||||
.minitabs-wrapper .minitabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid #ccc; }
|
|
||||||
.minitabs-wrapper .minitab {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #333;
|
|
||||||
background-color: #f1f1f1;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-bottom: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s; }
|
|
||||||
.minitabs-wrapper .minitab:hover {
|
|
||||||
background-color: #ddd; }
|
|
||||||
.minitabs-wrapper .minitab.active {
|
|
||||||
background-color: #fff;
|
|
||||||
font-weight: bold; }
|
|
||||||
|
|
||||||
body.preview-text-enabled {
|
|
||||||
/* layout of the page */
|
|
||||||
/* actual preview area */ }
|
|
||||||
@media (min-width: 800px) {
|
|
||||||
body.preview-text-enabled #filters-and-triggers > div {
|
|
||||||
display: flex;
|
|
||||||
/* Establishes Flexbox layout */
|
|
||||||
gap: 20px;
|
|
||||||
/* Adds space between the columns */
|
|
||||||
position: relative;
|
|
||||||
/* Ensures the sticky positioning is relative to this parent */ } }
|
|
||||||
body.preview-text-enabled #edit-text-filter, body.preview-text-enabled #text-preview {
|
|
||||||
flex: 1;
|
|
||||||
/* Each column takes an equal amount of available space */
|
|
||||||
align-self: flex-start;
|
|
||||||
/* Aligns the right column to the start, allowing it to maintain its content height */ }
|
|
||||||
body.preview-text-enabled #edit-text-filter #pro-tips {
|
|
||||||
display: none; }
|
|
||||||
body.preview-text-enabled #text-preview {
|
|
||||||
position: sticky;
|
|
||||||
top: 20px;
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
display: block !important; }
|
|
||||||
body.preview-text-enabled #activate-text-preview {
|
|
||||||
background-color: var(--color-grey-500); }
|
|
||||||
body.preview-text-enabled .monospace-preview {
|
|
||||||
background: var(--color-background-input);
|
|
||||||
border: 1px solid var(--color-grey-600);
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--color-text-input);
|
|
||||||
font-family: "Courier New", Courier, monospace;
|
|
||||||
/* Sets the font to a monospace type */
|
|
||||||
font-size: 70%;
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
/* Preserves whitespace and line breaks like <pre> */ }
|
|
||||||
|
|
||||||
#activate-text-preview {
|
|
||||||
right: 0;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 3;
|
|
||||||
box-shadow: 1px 1px 4px var(--color-shadow-jump); }
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-background-page);
|
background: var(--color-background-page);
|
||||||
@@ -576,26 +479,9 @@ button.toggle-button {
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
border-bottom: 2px solid var(--color-menu-accent);
|
||||||
align-items: center; }
|
align-items: center; }
|
||||||
|
|
||||||
#pure-menu-horizontal-spinner {
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
width: 100%;
|
|
||||||
animation: gradient 200s ease infinite; }
|
|
||||||
|
|
||||||
body.spinner-active #pure-menu-horizontal-spinner {
|
|
||||||
animation: gradient 1s ease infinite; }
|
|
||||||
|
|
||||||
@keyframes gradient {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 50%; }
|
|
||||||
50% {
|
|
||||||
background-position: 100% 50%; }
|
|
||||||
100% {
|
|
||||||
background-position: 0% 50%; } }
|
|
||||||
|
|
||||||
.pure-menu-heading {
|
.pure-menu-heading {
|
||||||
color: var(--color-text-menu-heading); }
|
color: var(--color-text-menu-heading); }
|
||||||
|
|
||||||
@@ -605,11 +491,8 @@ body.spinner-active #pure-menu-horizontal-spinner {
|
|||||||
background-color: var(--color-background-menu-link-hover);
|
background-color: var(--color-background-menu-link-hover);
|
||||||
color: var(--color-text-menu-link-hover); }
|
color: var(--color-text-menu-link-hover); }
|
||||||
|
|
||||||
.tab-pane-inner {
|
|
||||||
scroll-margin-top: 200px; }
|
|
||||||
|
|
||||||
section.content {
|
section.content {
|
||||||
padding-top: 100px;
|
padding-top: 5em;
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -751,6 +634,9 @@ a.pure-button-selected {
|
|||||||
.button-cancel {
|
.button-cancel {
|
||||||
background: var(--color-background-button-cancel); }
|
background: var(--color-background-button-cancel); }
|
||||||
|
|
||||||
|
#save_button {
|
||||||
|
margin-right: 1rem; }
|
||||||
|
|
||||||
.messages li {
|
.messages li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
@@ -949,10 +835,8 @@ footer {
|
|||||||
.pure-form .inline-radio ul {
|
.pure-form .inline-radio ul {
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
list-style: none; }
|
list-style: none; }
|
||||||
.pure-form .inline-radio ul li {
|
.pure-form .inline-radio ul li > * {
|
||||||
display: flex;
|
display: inline-block; }
|
||||||
align-items: center;
|
|
||||||
gap: 1em; }
|
|
||||||
|
|
||||||
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
|
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
|
||||||
.box {
|
.box {
|
||||||
@@ -988,24 +872,12 @@ footer {
|
|||||||
.watch-table thead {
|
.watch-table thead {
|
||||||
display: block; }
|
display: block; }
|
||||||
.watch-table thead tr th {
|
.watch-table thead tr th {
|
||||||
display: inline-block; } }
|
display: inline-block; }
|
||||||
@media only screen and (max-width: 760px) and (max-width: 768px), (min-device-width: 768px) and (max-device-width: 800px) and (max-width: 768px) {
|
|
||||||
.watch-table thead tr th .hide-on-mobile {
|
|
||||||
display: none; } }
|
|
||||||
|
|
||||||
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 800px) {
|
|
||||||
.watch-table thead .empty-cell {
|
.watch-table thead .empty-cell {
|
||||||
display: none; }
|
display: none; }
|
||||||
.watch-table tbody td,
|
.watch-table tbody td,
|
||||||
.watch-table tbody tr {
|
.watch-table tbody tr {
|
||||||
display: block; }
|
display: block; }
|
||||||
.watch-table tbody tr {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap; }
|
|
||||||
.watch-table tbody tr :nth-child(3) {
|
|
||||||
flex-grow: 1; }
|
|
||||||
.watch-table tbody tr :nth-last-child(-n+3) {
|
|
||||||
flex-basis: 100%; }
|
|
||||||
.watch-table .last-checked > span {
|
.watch-table .last-checked > span {
|
||||||
vertical-align: middle; }
|
vertical-align: middle; }
|
||||||
.watch-table .last-checked::before {
|
.watch-table .last-checked::before {
|
||||||
@@ -1097,10 +969,6 @@ textarea::placeholder {
|
|||||||
- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out
|
- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out
|
||||||
- Rely always on width in CSS
|
- Rely always on width in CSS
|
||||||
*/
|
*/
|
||||||
/** Set max width for input field */
|
|
||||||
.m-d {
|
|
||||||
min-width: 100%; }
|
|
||||||
|
|
||||||
@media only screen and (min-width: 761px) {
|
@media only screen and (min-width: 761px) {
|
||||||
/* m-d is medium-desktop */
|
/* m-d is medium-desktop */
|
||||||
.m-d {
|
.m-d {
|
||||||
@@ -1161,8 +1029,7 @@ body.full-width .edit-form {
|
|||||||
.edit-form {
|
.edit-form {
|
||||||
min-width: 70%;
|
min-width: 70%;
|
||||||
/* so it cant overflow */
|
/* so it cant overflow */
|
||||||
max-width: 95%;
|
max-width: 95%; }
|
||||||
/* Make action buttons have consistent size and spacing */ }
|
|
||||||
.edit-form .box-wrap {
|
.edit-form .box-wrap {
|
||||||
position: relative; }
|
position: relative; }
|
||||||
.edit-form .inner {
|
.edit-form .inner {
|
||||||
@@ -1171,10 +1038,6 @@ body.full-width .edit-form {
|
|||||||
.edit-form #actions {
|
.edit-form #actions {
|
||||||
display: block;
|
display: block;
|
||||||
background: var(--color-background); }
|
background: var(--color-background); }
|
||||||
.edit-form #actions .pure-control-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.625em;
|
|
||||||
flex-wrap: wrap; }
|
|
||||||
.edit-form .pure-form-message-inline {
|
.edit-form .pure-form-message-inline {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
color: var(--color-text-input-description); }
|
color: var(--color-text-input-description); }
|
||||||
@@ -1203,21 +1066,6 @@ ul {
|
|||||||
.time-check-widget tr input[type="number"] {
|
.time-check-widget tr input[type="number"] {
|
||||||
width: 5em; }
|
width: 5em; }
|
||||||
|
|
||||||
@media only screen and (max-width: 760px) {
|
|
||||||
.time-check-widget tbody {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr auto 1fr;
|
|
||||||
gap: 0.625em 0.3125em;
|
|
||||||
align-items: center; }
|
|
||||||
.time-check-widget tr {
|
|
||||||
display: contents; }
|
|
||||||
.time-check-widget tr th {
|
|
||||||
text-align: right;
|
|
||||||
padding-right: 5px; }
|
|
||||||
.time-check-widget tr input[type="number"] {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 5em; } }
|
|
||||||
|
|
||||||
#selector-wrapper {
|
#selector-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1346,9 +1194,11 @@ ul {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
opacity: 0.7; }
|
opacity: 0.7; }
|
||||||
|
|
||||||
|
|
||||||
.restock-label svg {
|
.restock-label svg {
|
||||||
vertical-align: middle; }
|
vertical-align: middle; }
|
||||||
|
|
||||||
|
|
||||||
#chrome-extension-link {
|
#chrome-extension-link {
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
border: 1px solid var(--color-grey-800);
|
border: 1px solid var(--color-grey-800);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from flask import (
|
|||||||
flash
|
flash
|
||||||
)
|
)
|
||||||
|
|
||||||
from .html_tools import TRANSLATE_WHITESPACE_TABLE
|
|
||||||
from . model import App, Watch
|
from . model import App, Watch
|
||||||
from copy import deepcopy, copy
|
from copy import deepcopy, copy
|
||||||
from os import path, unlink
|
from os import path, unlink
|
||||||
@@ -12,6 +11,7 @@ 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,7 +270,6 @@ 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 = {}
|
||||||
@@ -751,17 +750,17 @@ class ChangeDetectionStore:
|
|||||||
def update_5(self):
|
def update_5(self):
|
||||||
# If the watch notification body, title look the same as the global one, unset it, so the watch defaults back to using the main settings
|
# If the watch notification body, title look the same as the global one, unset it, so the watch defaults back to using the main settings
|
||||||
# In other words - the watch notification_title and notification_body are not needed if they are the same as the default one
|
# In other words - the watch notification_title and notification_body are not needed if they are the same as the default one
|
||||||
current_system_body = self.data['settings']['application']['notification_body'].translate(TRANSLATE_WHITESPACE_TABLE)
|
current_system_body = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n "))
|
||||||
current_system_title = self.data['settings']['application']['notification_body'].translate(TRANSLATE_WHITESPACE_TABLE)
|
current_system_title = self.data['settings']['application']['notification_body'].translate(str.maketrans('', '', "\r\n "))
|
||||||
for uuid, watch in self.data['watching'].items():
|
for uuid, watch in self.data['watching'].items():
|
||||||
try:
|
try:
|
||||||
watch_body = watch.get('notification_body', '')
|
watch_body = watch.get('notification_body', '')
|
||||||
if watch_body and watch_body.translate(TRANSLATE_WHITESPACE_TABLE) == current_system_body:
|
if watch_body and watch_body.translate(str.maketrans('', '', "\r\n ")) == current_system_body:
|
||||||
# Looks the same as the default one, so unset it
|
# Looks the same as the default one, so unset it
|
||||||
watch['notification_body'] = None
|
watch['notification_body'] = None
|
||||||
|
|
||||||
watch_title = watch.get('notification_title', '')
|
watch_title = watch.get('notification_title', '')
|
||||||
if watch_title and watch_title.translate(TRANSLATE_WHITESPACE_TABLE) == current_system_title:
|
if watch_title and watch_title.translate(str.maketrans('', '', "\r\n ")) == current_system_title:
|
||||||
# Looks the same as the default one, so unset it
|
# Looks the same as the default one, so unset it
|
||||||
watch['notification_title'] = None
|
watch['notification_title'] = None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -11,11 +11,8 @@
|
|||||||
class="notification-urls" )
|
class="notification-urls" )
|
||||||
}}
|
}}
|
||||||
<div class="pure-form-message-inline">
|
<div class="pure-form-message-inline">
|
||||||
<p>
|
<ul>
|
||||||
<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>
|
<li>Use <a target=_new href="https://github.com/caronc/apprise">AppRise URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.</li>
|
||||||
</p>
|
|
||||||
<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>
|
||||||
@@ -43,7 +40,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pure-controls">
|
<div class="pure-controls">
|
||||||
<div data-target="#notification-tokens-info" class="toggle-show pure-button button-tag button-xsmall">Show token/placeholders</div>
|
<div id="notification-token-toggle" class="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">
|
||||||
|
|||||||
@@ -33,11 +33,9 @@
|
|||||||
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="">
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="pure-menu-fixed" style="width: 100%;">
|
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed" id="nav-menu">
|
||||||
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
|
|
||||||
|
|
||||||
{% if has_password and not current_user.is_authenticated %}
|
{% if has_password and not current_user.is_authenticated %}
|
||||||
<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>
|
||||||
@@ -131,12 +129,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div id="pure-menu-horizontal-spinner"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% if hosted_sticky %}
|
{% if hosted_sticky %}
|
||||||
<div class="sticky-tab" id="hosted-sticky">
|
<div class="sticky-tab" id="hosted-sticky">
|
||||||
<a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>
|
<a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
{% 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 }}');
|
||||||
@@ -24,8 +23,9 @@
|
|||||||
const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}";
|
const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}";
|
||||||
const default_system_fetch_backend="{{ settings_application['fetch_backend'] }}";
|
const default_system_fetch_backend="{{ settings_application['fetch_backend'] }}";
|
||||||
</script>
|
</script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script>
|
|
||||||
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
|
||||||
|
<script src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script>
|
||||||
{% if playwright_enabled %}
|
{% if playwright_enabled %}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if watch['processor'] == 'text_json_diff' %}
|
{% if watch['processor'] == 'text_json_diff' %}
|
||||||
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
|
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
|
||||||
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
|
<li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="tab"><a href="#notifications">Notifications</a></li>
|
<li class="tab"><a href="#notifications">Notifications</a></li>
|
||||||
<li class="tab"><a href="#stats">Stats</a></li>
|
<li class="tab"><a href="#stats">Stats</a></li>
|
||||||
@@ -199,7 +199,7 @@ User-Agent: wonderbra 1.0") }}
|
|||||||
<div id="loading-status-text" style="display: none;">Please wait, first browser step can take a little time to load..<div class="spinner"></div></div>
|
<div id="loading-status-text" style="display: none;">Please wait, first browser step can take a little time to load..<div class="spinner"></div></div>
|
||||||
<div class="flex-wrapper" >
|
<div class="flex-wrapper" >
|
||||||
|
|
||||||
<div id="browser-steps-ui" class="noselect">
|
<div id="browser-steps-ui" class="noselect" style="width: 100%; background-color: #eee; border-radius: 5px;">
|
||||||
|
|
||||||
<div class="noselect" id="browsersteps-selector-wrapper" style="width: 100%">
|
<div class="noselect" id="browsersteps-selector-wrapper" style="width: 100%">
|
||||||
<span class="loader" >
|
<span class="loader" >
|
||||||
@@ -214,7 +214,7 @@ User-Agent: wonderbra 1.0") }}
|
|||||||
<canvas class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas>
|
<canvas class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="browser-steps-fieldlist" >
|
<div id="browser-steps-fieldlist" style="padding-left: 1em; width: 350px; font-size: 80%;" >
|
||||||
<span id="browser-seconds-remaining">Loading</span> <span style="font-size: 80%;"> (<a target=_new href="https://github.com/dgtlmoon/changedetection.io/pull/478/files#diff-1a79d924d1840c485238e66772391268a89c95b781d69091384cf1ea1ac146c9R4">?</a>) </span>
|
<span id="browser-seconds-remaining">Loading</span> <span style="font-size: 80%;"> (<a target=_new href="https://github.com/dgtlmoon/changedetection.io/pull/478/files#diff-1a79d924d1840c485238e66772391268a89c95b781d69091384cf1ea1ac146c9R4">?</a>) </span>
|
||||||
{{ render_field(form.browser_steps) }}
|
{{ render_field(form.browser_steps) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,10 +253,7 @@ User-Agent: wonderbra 1.0") }}
|
|||||||
|
|
||||||
{% if watch['processor'] == 'text_json_diff' %}
|
{% if watch['processor'] == 'text_json_diff' %}
|
||||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||||
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span>
|
<div class="pure-control-group">
|
||||||
<div>
|
|
||||||
<div id="edit-text-filter">
|
|
||||||
<div class="pure-control-group" id="pro-tips">
|
|
||||||
<strong>Pro-tips:</strong><br>
|
<strong>Pro-tips:</strong><br>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
@@ -278,9 +275,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 CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
|
<span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br>
|
||||||
<p><div data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div><br></p>
|
|
||||||
<ul id="advanced-help-selectors" style="display: none;">
|
<ul>
|
||||||
<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>
|
||||||
@@ -300,25 +297,21 @@ 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>
|
||||||
<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
|
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">
|
||||||
{{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header
|
{{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header
|
||||||
footer
|
footer
|
||||||
nav
|
nav
|
||||||
.stockticker
|
.stockticker") }}
|
||||||
//*[contains(text(), 'Advertisement')]") }}
|
|
||||||
<span class="pure-form-message-inline">
|
<span class="pure-form-message-inline">
|
||||||
<ul>
|
<ul>
|
||||||
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
|
<li> Remove HTML element(s) by CSS selector before text conversion. </li>
|
||||||
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
|
<li> Don't paste HTML here, use only CSS selectors </li>
|
||||||
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
|
<li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -329,25 +322,18 @@ nav
|
|||||||
{{ render_checkbox_field(form.filter_text_added) }}
|
{{ render_checkbox_field(form.filter_text_added) }}
|
||||||
{{ render_checkbox_field(form.filter_text_replaced) }}
|
{{ render_checkbox_field(form.filter_text_replaced) }}
|
||||||
{{ render_checkbox_field(form.filter_text_removed) }}
|
{{ render_checkbox_field(form.filter_text_removed) }}
|
||||||
<span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span><br>
|
<span class="pure-form-message-inline">Note: Depending on the length and similarity of the text on each line, the algorithm may consider an <strong>addition</strong> instead of <strong>replacement</strong> for example.</span>
|
||||||
<span class="pure-form-message-inline"> So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br>
|
<span class="pure-form-message-inline">So it's always better to select <strong>Added</strong>+<strong>Replaced</strong> when you're interested in new content.</span><br>
|
||||||
<span class="pure-form-message-inline"> When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span>
|
<span class="pure-form-message-inline">When content is merely moved in a list, it will also trigger an <strong>addition</strong>, consider enabling <code><strong>Only trigger when unique lines appear</strong></code></span>
|
||||||
</fieldset>
|
|
||||||
<fieldset class="pure-control-group">
|
|
||||||
{{ render_checkbox_field(form.check_unique_lines) }}
|
|
||||||
<span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="pure-control-group">
|
|
||||||
{{ render_checkbox_field(form.remove_duplicate_lines) }}
|
|
||||||
<span class="pure-form-message-inline">Remove duplicate lines of text</span>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="pure-control-group">
|
<fieldset class="pure-control-group">
|
||||||
{{ render_checkbox_field(form.sort_text_alphabetically) }}
|
{{ render_checkbox_field(form.sort_text_alphabetically) }}
|
||||||
<span class="pure-form-message-inline">Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below.</span>
|
<span class="pure-form-message-inline">Helps reduce changes detected caused by sites shuffling lines around, combine with <i>check unique lines</i> below.</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="pure-control-group">
|
<fieldset class="pure-control-group">
|
||||||
{{ render_checkbox_field(form.trim_text_whitespace) }}
|
{{ render_checkbox_field(form.check_unique_lines) }}
|
||||||
<span class="pure-form-message-inline">Remove any whitespace before and after each line of text</span>
|
<span class="pure-form-message-inline">Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch.</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
@@ -370,10 +356,10 @@ nav
|
|||||||
") }}
|
") }}
|
||||||
<span class="pure-form-message-inline">
|
<span class="pure-form-message-inline">
|
||||||
<ul>
|
<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>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>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
|
||||||
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
|
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
|
||||||
|
<li>Use the preview/show current tab to see ignores</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -397,9 +383,7 @@ Unavailable") }}
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
{{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/
|
{{ render_field(form.extract_text, rows=5, placeholder="\d+ online") }}
|
||||||
or
|
|
||||||
keyword") }}
|
|
||||||
<span class="pure-form-message-inline">
|
<span class="pure-form-message-inline">
|
||||||
<ul>
|
<ul>
|
||||||
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
|
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
|
||||||
@@ -419,26 +403,6 @@ keyword") }}
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="text-preview" style="display: none;" >
|
|
||||||
<script>
|
|
||||||
const preview_text_edit_filters_url="{{url_for('watch_get_preview_rendered', uuid=uuid)}}";
|
|
||||||
</script>
|
|
||||||
<br>
|
|
||||||
{#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#}
|
|
||||||
<div class="minitabs-wrapper">
|
|
||||||
<div class="minitabs-content">
|
|
||||||
<div id="text-preview-inner" class="monospace-preview">
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
<div id="text-preview-before-inner" style="display: none;" class="monospace-preview">
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# rendered sub Template #}
|
{# rendered sub Template #}
|
||||||
{% if extra_form_content %}
|
{% if extra_form_content %}
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<script>
|
<script>
|
||||||
const screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid)}}";
|
const screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid)}}";
|
||||||
const triggered_line_numbers = {{ triggered_line_numbers|tojson }};
|
|
||||||
{% if last_error_screenshot %}
|
{% if last_error_screenshot %}
|
||||||
const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
|
const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
|
||||||
{% endif %}
|
{% endif %}
|
||||||
const highlight_submit_ignore_url = "{{url_for('highlight_submit_ignore_url', uuid=uuid)}}";
|
const highlight_submit_ignore_url = "{{url_for('highlight_submit_ignore_url', uuid=uuid)}}";
|
||||||
</script>
|
</script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='plugins.js')}}"></script>
|
|
||||||
<script src="{{ url_for('static_content', group='js', filename='diff-overview.js') }}" defer></script>
|
<script src="{{ url_for('static_content', group='js', filename='diff-overview.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static_content', group='js', filename='preview.js') }}" defer></script>
|
<script src="{{ url_for('static_content', group='js', filename='preview.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='tabs.js') }}" defer></script>
|
||||||
@@ -69,15 +67,16 @@
|
|||||||
|
|
||||||
<div class="tab-pane-inner" id="text">
|
<div class="tab-pane-inner" id="text">
|
||||||
<div class="snapshot-age">{{ current_version|format_timestamp_timeago }}</div>
|
<div class="snapshot-age">{{ current_version|format_timestamp_timeago }}</div>
|
||||||
|
<span class="ignored">Grey lines are ignored</span> <span class="triggered">Blue lines are triggers</span>
|
||||||
<span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span>
|
<span class="tip"><strong>Pro-tip</strong>: Highlight text to add to ignore filters</span>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td id="diff-col" class="highlightable-filter">
|
<td id="diff-col" class="highlightable-filter">
|
||||||
<pre style="border-left: 2px solid #ddd;">
|
{% for row in content %}
|
||||||
{{ content }}
|
<div class="{{ row.classes }}">{{ row.line }}</div>
|
||||||
</pre>
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -155,13 +155,11 @@
|
|||||||
{{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder="header
|
{{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder="header
|
||||||
footer
|
footer
|
||||||
nav
|
nav
|
||||||
.stockticker
|
.stockticker") }}
|
||||||
//*[contains(text(), 'Advertisement')]") }}
|
|
||||||
<span class="pure-form-message-inline">
|
<span class="pure-form-message-inline">
|
||||||
<ul>
|
<ul>
|
||||||
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
|
<li> Remove HTML element(s) by CSS selector before text conversion. </li>
|
||||||
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
|
<li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
|
||||||
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -172,11 +170,11 @@ nav
|
|||||||
<span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br>
|
<span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br>
|
||||||
<span class="pure-form-message-inline">
|
<span class="pure-form-message-inline">
|
||||||
<ul>
|
<ul>
|
||||||
<li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li>
|
|
||||||
<li>Note: This is applied globally in addition to the per-watch rules.</li>
|
<li>Note: This is applied globally in addition to the per-watch rules.</li>
|
||||||
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
|
<li>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>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
|
||||||
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
|
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
|
||||||
|
<li>Use the preview/show current tab to see ignores</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -78,8 +78,8 @@
|
|||||||
{% 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('index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}">Last Checked <span class='arrow {{link_order}}'></span></a></th>
|
||||||
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}"><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('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th>
|
||||||
<th class="empty-cell"></th>
|
<th class="empty-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -191,9 +191,9 @@
|
|||||||
{% if watch.history_n >= 2 %}
|
{% if watch.history_n >= 2 %}
|
||||||
|
|
||||||
{% if is_unviewed %}
|
{% if is_unviewed %}
|
||||||
<a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_next_snapshot_key_to_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
|
<a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_next_snapshot_key_to_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">Diff</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a>
|
<a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">Diff</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# A list of real world examples!
|
|
||||||
|
|
||||||
Always the price should be 666.66 for our tests
|
|
||||||
|
|
||||||
see test_restock_itemprop.py::test_special_prop_examples
|
|
||||||
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<div class="PriceSection PriceSection_PriceSection__Vx1_Q PriceSection_variantHuge__P9qxg PdpPriceSection"
|
|
||||||
data-testid="price-section"
|
|
||||||
data-optly-product-tile-price-section="true"><span
|
|
||||||
class="PriceRange ProductPrice variant-huge" itemprop="offers"
|
|
||||||
itemscope="" itemtype="http://schema.org/Offer"><div
|
|
||||||
class="VisuallyHidden_VisuallyHidden__VBD83">$155.55</div><span
|
|
||||||
aria-hidden="true" class="Price variant-huge" data-testid="price"
|
|
||||||
itemprop="price"><sup class="sup" data-testid="price-symbol"
|
|
||||||
itemprop="priceCurrency" content="AUD">$</sup><span
|
|
||||||
class="dollars" data-testid="price-value" itemprop="price"
|
|
||||||
content="155.55">155.55</span><span class="extras"><span class="sup"
|
|
||||||
data-testid="price-sup"></span></span></span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="application/ld+json">{
|
|
||||||
"@type": "Product",
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"name": "test",
|
|
||||||
"description": "test",
|
|
||||||
"offers": {
|
|
||||||
"@type": "Offer",
|
|
||||||
"priceCurrency": "AUD",
|
|
||||||
"price": 155.55
|
|
||||||
},
|
|
||||||
}</script>
|
|
||||||
@@ -16,4 +16,4 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert b"1 Imported" in res.data
|
assert b"1 Imported" in res.data
|
||||||
wait_for_all_checks(client)
|
time.sleep(3)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def test_select_custom(client, live_server, measure_memory_usage):
|
|||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
# We should see something via proxy
|
# We should see something via proxy
|
||||||
assert b' - 0.' in res.data
|
assert b'<div class=""> - 0.' in res.data
|
||||||
|
|
||||||
#
|
#
|
||||||
# Now we should see the request in the container logs for "squid-squid-custom" because it will be the only default
|
# Now we should see the request in the container logs for "squid-squid-custom" because it will be the only default
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
|
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks
|
||||||
|
|
||||||
|
|
||||||
def set_response():
|
|
||||||
import time
|
|
||||||
data = f"""<html>
|
|
||||||
<body>
|
|
||||||
<h1>Awesome, you made it</h1>
|
|
||||||
yeah the socks request worked
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open("test-datastore/endpoint-content.txt", "w") as f:
|
|
||||||
f.write(data)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
def test_socks5(client, live_server, measure_memory_usage):
|
def test_socks5(client, live_server, measure_memory_usage):
|
||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
set_response()
|
|
||||||
|
|
||||||
# Setup a proxy
|
# Setup a proxy
|
||||||
res = client.post(
|
res = client.post(
|
||||||
@@ -39,10 +24,7 @@ def test_socks5(client, live_server, measure_memory_usage):
|
|||||||
|
|
||||||
assert b"Settings updated." in res.data
|
assert b"Settings updated." in res.data
|
||||||
|
|
||||||
# Because the socks server should connect back to us
|
test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '')
|
||||||
test_url = url_for('test_endpoint', _external=True) + f"?socks-test-tag={os.getenv('SOCKSTEST', '')}"
|
|
||||||
test_url = test_url.replace('localhost.localdomain', 'cdio')
|
|
||||||
test_url = test_url.replace('localhost', 'cdio')
|
|
||||||
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("form_quick_watch_add"),
|
url_for("form_quick_watch_add"),
|
||||||
@@ -78,25 +60,4 @@ def test_socks5(client, live_server, measure_memory_usage):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Should see the proper string
|
# Should see the proper string
|
||||||
assert "Awesome, you made it".encode('utf-8') in res.data
|
assert "+0200:".encode('utf-8') in res.data
|
||||||
|
|
||||||
# PROXY CHECKER WIDGET CHECK - this needs more checking
|
|
||||||
uuid = extract_UUID_from_client(client)
|
|
||||||
|
|
||||||
res = client.get(
|
|
||||||
url_for("check_proxies.start_check", uuid=uuid),
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
# It's probably already finished super fast :(
|
|
||||||
#assert b"RUNNING" in res.data
|
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
res = client.get(
|
|
||||||
url_for("check_proxies.get_recheck_status", uuid=uuid),
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
assert b"OK" in res.data
|
|
||||||
|
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
|
||||||
assert b'Deleted' in res.data
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks
|
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks
|
||||||
|
|
||||||
|
|
||||||
def set_response():
|
|
||||||
import time
|
|
||||||
data = f"""<html>
|
|
||||||
<body>
|
|
||||||
<h1>Awesome, you made it</h1>
|
|
||||||
yeah the socks request worked
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open("test-datastore/endpoint-content.txt", "w") as f:
|
|
||||||
f.write(data)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
# should be proxies.json mounted from run_proxy_tests.sh already
|
# should be proxies.json mounted from run_proxy_tests.sh already
|
||||||
# -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json
|
# -v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json
|
||||||
def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage):
|
def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage):
|
||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
set_response()
|
|
||||||
# Because the socks server should connect back to us
|
test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '')
|
||||||
test_url = url_for('test_endpoint', _external=True) + f"?socks-test-tag={os.getenv('SOCKSTEST', '')}"
|
|
||||||
test_url = test_url.replace('localhost.localdomain', 'cdio')
|
|
||||||
test_url = test_url.replace('localhost', 'cdio')
|
|
||||||
|
|
||||||
res = client.get(url_for("settings_page"))
|
res = client.get(url_for("settings_page"))
|
||||||
assert b'name="requests-proxy" type="radio" value="socks5proxy"' in res.data
|
assert b'name="requests-proxy" type="radio" value="socks5proxy"' in res.data
|
||||||
@@ -65,4 +49,4 @@ def test_socks5_from_proxiesjson_file(client, live_server, measure_memory_usage)
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Should see the proper string
|
# Should see the proper string
|
||||||
assert "Awesome, you made it".encode('utf-8') in res.data
|
assert "+0200:".encode('utf-8') in res.data
|
||||||
|
|||||||
@@ -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, wait_for_notification_endpoint_output
|
from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
|
||||||
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
|
||||||
wait_for_notification_endpoint_output()
|
time.sleep(2)
|
||||||
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,7 +103,6 @@ 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, wait_for_notification_endpoint_output
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
from changedetectionio import html_tools
|
from changedetectionio import html_tools
|
||||||
|
|
||||||
|
|
||||||
@@ -39,8 +39,9 @@ def test_setup(client, live_server, measure_memory_usage):
|
|||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
|
|
||||||
def test_check_removed_line_contains_trigger(client, live_server, measure_memory_usage):
|
def test_check_removed_line_contains_trigger(client, live_server, measure_memory_usage):
|
||||||
#live_server_setup(live_server)
|
|
||||||
# Give the endpoint time to spin up
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
set_original()
|
set_original()
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
@@ -77,8 +78,6 @@ def test_check_removed_line_contains_trigger(client, live_server, measure_memory
|
|||||||
|
|
||||||
# The trigger line is REMOVED, this should trigger
|
# The trigger line is REMOVED, this should trigger
|
||||||
set_original(excluding='The golden line')
|
set_original(excluding='The golden line')
|
||||||
|
|
||||||
# Check in the processor here what's going on, its triggering empty-reply and no change.
|
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
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"))
|
||||||
@@ -154,7 +153,6 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
|
|||||||
# A line thats not the trigger should not trigger anything
|
# A line thats not the trigger should not trigger anything
|
||||||
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)
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("index"))
|
||||||
assert b'unviewed' not in res.data
|
assert b'unviewed' not in res.data
|
||||||
@@ -167,12 +165,13 @@ 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
|
||||||
wait_for_notification_endpoint_output()
|
time.sleep(3)
|
||||||
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()
|
||||||
assert b'-Oh yes please-' in response
|
assert b'-Oh yes please-' in response
|
||||||
assert '网站监测 内容更新了'.encode('utf-8') in response
|
assert '网站监测 内容更新了'.encode('utf-8') in response
|
||||||
|
|
||||||
|
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
assert b'Deleted' in res.data
|
assert b'Deleted' in res.data
|
||||||
|
|||||||
@@ -69,12 +69,6 @@ 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
|
||||||
@@ -92,7 +86,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=uuid))
|
res = client.get(url_for("diff_history_page", uuid="first"))
|
||||||
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
|
||||||
@@ -149,12 +143,18 @@ 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)
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
|
|||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
# Use a mix of case in ZzZ to prove it works case-insensitive.
|
# Use a mix of case in ZzZ to prove it works case-insensitive.
|
||||||
ignore_text = "out of stoCk\r\nfoobar"
|
ignore_text = "out of stoCk\r\nfoobar"
|
||||||
|
|
||||||
set_original_ignore_response()
|
set_original_ignore_response()
|
||||||
|
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
@@ -124,24 +127,13 @@ def test_check_block_changedetection_text_NOT_present(client, live_server, measu
|
|||||||
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
|
||||||
|
|
||||||
# 2548
|
|
||||||
# Going back to the ORIGINAL should NOT trigger a change
|
|
||||||
set_original_ignore_response()
|
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
res = client.get(url_for("index"))
|
|
||||||
assert b'unviewed' not in res.data
|
|
||||||
|
|
||||||
|
# Now we set a change where the text is gone, it should now trigger
|
||||||
# Now we set a change where the text is gone AND its different content, it should now trigger
|
|
||||||
set_modified_response_minus_block_text()
|
set_modified_response_minus_block_text()
|
||||||
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'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)
|
||||||
assert b'Deleted' in res.data
|
assert b'Deleted' in res.data
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import time
|
|||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
from ..html_tools import *
|
from ..html_tools import *
|
||||||
from .util import live_server_setup, wait_for_all_checks
|
from .util import live_server_setup
|
||||||
|
|
||||||
|
|
||||||
def test_setup(live_server):
|
def test_setup(live_server):
|
||||||
@@ -87,9 +87,6 @@ def test_element_removal_output():
|
|||||||
Some initial text<br>
|
Some initial text<br>
|
||||||
<p>across multiple lines</p>
|
<p>across multiple lines</p>
|
||||||
<div id="changetext">Some text that changes</div>
|
<div id="changetext">Some text that changes</div>
|
||||||
<div>Some text should be matched by xPath // selector</div>
|
|
||||||
<div>Some text should be matched by xPath selector</div>
|
|
||||||
<div>Some text should be matched by xPath1 selector</div>
|
|
||||||
</body>
|
</body>
|
||||||
<footer>
|
<footer>
|
||||||
<p>Footer</p>
|
<p>Footer</p>
|
||||||
@@ -97,16 +94,7 @@ def test_element_removal_output():
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
html_blob = element_removal(
|
html_blob = element_removal(
|
||||||
[
|
["header", "footer", "nav", "#changetext"], html_content=content
|
||||||
"header",
|
|
||||||
"footer",
|
|
||||||
"nav",
|
|
||||||
"#changetext",
|
|
||||||
"//*[contains(text(), 'xPath // selector')]",
|
|
||||||
"xpath://*[contains(text(), 'xPath selector')]",
|
|
||||||
"xpath1://*[contains(text(), 'xPath1 selector')]"
|
|
||||||
],
|
|
||||||
html_content=content
|
|
||||||
)
|
)
|
||||||
text = get_text(html_blob)
|
text = get_text(html_blob)
|
||||||
assert (
|
assert (
|
||||||
@@ -119,10 +107,12 @@ across multiple lines
|
|||||||
|
|
||||||
|
|
||||||
def test_element_removal_full(client, live_server, measure_memory_usage):
|
def test_element_removal_full(client, live_server, measure_memory_usage):
|
||||||
#live_server_setup(live_server)
|
sleep_time_for_fetch_thread = 3
|
||||||
|
|
||||||
set_original_response()
|
set_original_response()
|
||||||
|
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
test_url = url_for("test_endpoint", _external=True)
|
test_url = url_for("test_endpoint", _external=True)
|
||||||
@@ -130,8 +120,7 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
|
|||||||
url_for("import_page"), data={"urls": test_url}, follow_redirects=True
|
url_for("import_page"), data={"urls": test_url}, follow_redirects=True
|
||||||
)
|
)
|
||||||
assert b"1 Imported" in res.data
|
assert b"1 Imported" in res.data
|
||||||
wait_for_all_checks(client)
|
time.sleep(1)
|
||||||
|
|
||||||
# Goto the edit page, add the filter data
|
# Goto the edit page, add the filter data
|
||||||
# Not sure why \r needs to be added - absent of the #changetext this is not necessary
|
# Not sure why \r needs to be added - absent of the #changetext this is not necessary
|
||||||
subtractive_selectors_data = "header\r\nfooter\r\nnav\r\n#changetext"
|
subtractive_selectors_data = "header\r\nfooter\r\nnav\r\n#changetext"
|
||||||
@@ -147,7 +136,6 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
|
|||||||
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)
|
|
||||||
|
|
||||||
# Check it saved
|
# Check it saved
|
||||||
res = client.get(
|
res = client.get(
|
||||||
@@ -156,10 +144,10 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
|
|||||||
assert bytes(subtractive_selectors_data.encode("utf-8")) in res.data
|
assert bytes(subtractive_selectors_data.encode("utf-8")) in res.data
|
||||||
|
|
||||||
# Trigger a check
|
# Trigger a check
|
||||||
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
assert b'1 watches queued for rechecking.' in res.data
|
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
# Give the thread time to pick it up
|
||||||
|
time.sleep(sleep_time_for_fetch_thread)
|
||||||
|
|
||||||
# so that we set the state to 'unviewed' after all the edits
|
# so that we set the state to 'unviewed' after all the edits
|
||||||
client.get(url_for("diff_history_page", uuid="first"))
|
client.get(url_for("diff_history_page", uuid="first"))
|
||||||
@@ -168,11 +156,10 @@ def test_element_removal_full(client, live_server, measure_memory_usage):
|
|||||||
set_modified_response()
|
set_modified_response()
|
||||||
|
|
||||||
# Trigger a check
|
# Trigger a check
|
||||||
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
assert b'1 watches queued for rechecking.' in res.data
|
|
||||||
|
|
||||||
# Give the thread time to pick it up
|
# Give the thread time to pick it up
|
||||||
wait_for_all_checks(client)
|
time.sleep(sleep_time_for_fetch_thread)
|
||||||
|
|
||||||
# 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("index"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
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
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@@ -38,11 +38,6 @@ def test_check_encoding_detection(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)
|
||||||
|
|
||||||
|
|
||||||
# Content type recording worked
|
|
||||||
uuid = extract_UUID_from_client(client)
|
|
||||||
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == "text/html"
|
|
||||||
|
|
||||||
res = client.get(
|
res = client.get(
|
||||||
url_for("preview_page", uuid="first"),
|
url_for("preview_page", uuid="first"),
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def test_setup(client, live_server, measure_memory_usage):
|
|||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
|
|
||||||
def test_check_filter_multiline(client, live_server, measure_memory_usage):
|
def test_check_filter_multiline(client, live_server, measure_memory_usage):
|
||||||
# live_server_setup(live_server)
|
#live_server_setup(live_server)
|
||||||
set_multiline_response()
|
set_multiline_response()
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
@@ -115,9 +115,9 @@ def test_check_filter_multiline(client, live_server, measure_memory_usage):
|
|||||||
# Plaintext that doesnt look like a regex should match also
|
# Plaintext that doesnt look like a regex should match also
|
||||||
assert b'and this should be' in res.data
|
assert b'and this should be' in res.data
|
||||||
|
|
||||||
assert b'Something' in res.data
|
assert b'<div class="">Something' in res.data
|
||||||
assert b'across 6 billion multiple' in res.data
|
assert b'<div class="">across 6 billion multiple' in res.data
|
||||||
assert b'lines' in res.data
|
assert b'<div class="">lines' in res.data
|
||||||
|
|
||||||
# but the last one, which also says 'lines' shouldnt be here (non-greedy match checking)
|
# but the last one, which also says 'lines' shouldnt be here (non-greedy match checking)
|
||||||
assert b'aaand something lines' not in res.data
|
assert b'aaand something lines' not in res.data
|
||||||
@@ -183,19 +183,20 @@ def test_check_filter_and_regex_extract(client, live_server, measure_memory_usag
|
|||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
assert b'1000 online' in res.data
|
# Class will be blank for now because the frontend didnt apply the diff
|
||||||
|
assert b'<div class="">1000 online' in res.data
|
||||||
|
|
||||||
# All regex matching should be here
|
# All regex matching should be here
|
||||||
assert b'2000 online' in res.data
|
assert b'<div class="">2000 online' in res.data
|
||||||
|
|
||||||
# Both regexs should be here
|
# Both regexs should be here
|
||||||
assert b'80 guests' in res.data
|
assert b'<div class="">80 guests' in res.data
|
||||||
|
|
||||||
# Regex with flag handling should be here
|
# Regex with flag handling should be here
|
||||||
assert b'SomeCase insensitive 3456' in res.data
|
assert b'<div class="">SomeCase insensitive 3456' in res.data
|
||||||
|
|
||||||
# Singular group from /somecase insensitive (345\d)/i
|
# Singular group from /somecase insensitive (345\d)/i
|
||||||
assert b'3456' in res.data
|
assert b'<div class="">3456' in res.data
|
||||||
|
|
||||||
# Regex with multiline flag handling should be here
|
# Regex with multiline flag handling should be here
|
||||||
|
|
||||||
|
|||||||
@@ -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, wait_for_notification_endpoint_output
|
from .util import set_original_response, live_server_setup
|
||||||
from changedetectionio.model import App
|
from changedetectionio.model import App
|
||||||
|
|
||||||
|
|
||||||
@@ -102,15 +102,14 @@ 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
|
||||||
wait_for_notification_endpoint_output()
|
time.sleep(3)
|
||||||
|
|
||||||
# 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,9 +1,7 @@
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -28,12 +26,6 @@ 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"),
|
||||||
@@ -42,20 +34,28 @@ 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("import_page"),
|
url_for("form_quick_watch_add"),
|
||||||
data={"urls": test_url},
|
data={"url": test_url, "tags": ''},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
assert b"1 Imported" in res.data
|
assert b"Watch added" in res.data
|
||||||
|
|
||||||
|
# Give the thread time to pick up the first version
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
uuid = extract_UUID_from_client(client)
|
# Goto the edit page, add our ignore text
|
||||||
|
# Add our URL to the import page
|
||||||
|
url = url_for('test_notification_endpoint', _external=True)
|
||||||
|
notification_url = url.replace('http', 'json')
|
||||||
|
|
||||||
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure"
|
print(">>>> Notification URL: " + notification_url)
|
||||||
|
|
||||||
watch_data = {"notification_urls": notification_url,
|
# Just a regular notification setting, this will be used by the special 'filter not found' notification
|
||||||
|
notification_form_data = {"notification_urls": notification_url,
|
||||||
"notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
|
"notification_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"
|
||||||
@@ -69,65 +69,48 @@ def run_filter_test(client, live_server, content_filter):
|
|||||||
"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',
|
notification_form_data.update({
|
||||||
"headers": "",
|
"url": test_url,
|
||||||
"tags": "my tag",
|
"tags": "my tag",
|
||||||
"title": "my title 123",
|
"title": "my title 123",
|
||||||
"time_between_check-hours": 5, # So that the queue runner doesnt also put it in
|
"headers": "",
|
||||||
"url": test_url,
|
"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=uuid),
|
url_for("edit_page", uuid="first"),
|
||||||
data=watch_data,
|
data=notification_form_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 add a filter, because recheck hours == 5, ONLY pressing of the [edit] or [recheck all] should trigger
|
# Now the notification should not exist, because we didnt reach the threshold
|
||||||
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)
|
||||||
# Add 4 more checks
|
for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT-2):
|
||||||
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
|
||||||
|
assert not os.path.isfile("test-datastore/notification.txt"), f"test-datastore/notification.txt should not exist - Attempt {i} when threshold is {App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT}"
|
||||||
|
|
||||||
|
# We should see something in the frontend
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("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")
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 5
|
|
||||||
|
|
||||||
time.sleep(2)
|
|
||||||
# One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold
|
# 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)
|
||||||
wait_for_notification_endpoint_output()
|
time.sleep(2) # delay for apprise to fire
|
||||||
|
|
||||||
# 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()
|
||||||
|
|
||||||
@@ -140,11 +123,10 @@ 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, ATTEMPT_THRESHOLD_SETTING + 2):
|
for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT):
|
||||||
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)
|
||||||
@@ -153,6 +135,9 @@ 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
|
||||||
@@ -167,11 +152,9 @@ 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
|
||||||
|
|||||||
@@ -33,17 +33,13 @@ def test_strip_regex_text_func():
|
|||||||
|
|
||||||
stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines)
|
stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines)
|
||||||
|
|
||||||
assert "but 1 lines" in stripped_content
|
assert b"but 1 lines" in stripped_content
|
||||||
assert "igNORe-cAse text" not in stripped_content
|
assert b"igNORe-cAse text" not in stripped_content
|
||||||
assert "but 1234 lines" not in stripped_content
|
assert b"but 1234 lines" not in stripped_content
|
||||||
assert "really" not in stripped_content
|
assert b"really" not in stripped_content
|
||||||
assert "not this" not in stripped_content
|
assert b"not this" not in stripped_content
|
||||||
|
|
||||||
# Check line number reporting
|
# Check line number reporting
|
||||||
stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines, mode="line numbers")
|
stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines, mode="line numbers")
|
||||||
assert stripped_content == [2, 5, 6, 7, 8, 10]
|
assert stripped_content == [2, 5, 6, 7, 8, 10]
|
||||||
|
|
||||||
# Check that linefeeds are preserved when there are is no matching ignores
|
|
||||||
content = "some text\n\nand other text\n"
|
|
||||||
stripped_content = html_tools.strip_ignore_text(content, ignore_lines)
|
|
||||||
assert content == stripped_content
|
|
||||||
|
|||||||
@@ -22,15 +22,10 @@ def test_strip_text_func():
|
|||||||
ignore_lines = ["sometimes"]
|
ignore_lines = ["sometimes"]
|
||||||
|
|
||||||
stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines)
|
stripped_content = html_tools.strip_ignore_text(test_content, ignore_lines)
|
||||||
assert "sometimes" not in stripped_content
|
|
||||||
assert "Some content" in stripped_content
|
|
||||||
|
|
||||||
# Check that line feeds dont get chewed up when something is found
|
assert b"sometimes" not in stripped_content
|
||||||
test_content = "Some initial text\n\nWhich is across multiple lines\n\nZZZZz\n\n\nSo let's see what happens."
|
assert b"Some content" in stripped_content
|
||||||
ignore = ['something irrelevent but just to check', 'XXXXX', 'YYYYY', 'ZZZZZ']
|
|
||||||
|
|
||||||
stripped_content = html_tools.strip_ignore_text(test_content, ignore)
|
|
||||||
assert stripped_content == "Some initial text\n\nWhich is across multiple lines\n\n\n\nSo let's see what happens."
|
|
||||||
|
|
||||||
def set_original_ignore_response():
|
def set_original_ignore_response():
|
||||||
test_return_data = """<html>
|
test_return_data = """<html>
|
||||||
@@ -84,14 +79,14 @@ def set_modified_ignore_response():
|
|||||||
f.write(test_return_data)
|
f.write(test_return_data)
|
||||||
|
|
||||||
|
|
||||||
# Ignore text now just removes it entirely, is a LOT more simpler code this way
|
|
||||||
|
|
||||||
def test_check_ignore_text_functionality(client, live_server, measure_memory_usage):
|
def test_check_ignore_text_functionality(client, live_server, measure_memory_usage):
|
||||||
|
|
||||||
# Use a mix of case in ZzZ to prove it works case-insensitive.
|
# Use a mix of case in ZzZ to prove it works case-insensitive.
|
||||||
ignore_text = "XXXXX\r\nYYYYY\r\nzZzZZ\r\nnew ignore stuff"
|
ignore_text = "XXXXX\r\nYYYYY\r\nzZzZZ\r\nnew ignore stuff"
|
||||||
set_original_ignore_response()
|
set_original_ignore_response()
|
||||||
|
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
@@ -146,6 +141,8 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Just to be sure.. set a regular modified change..
|
# Just to be sure.. set a regular modified change..
|
||||||
set_modified_original_ignore_response()
|
set_modified_original_ignore_response()
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
@@ -154,19 +151,21 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
|
|||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("index"))
|
||||||
assert b'unviewed' in res.data
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
|
# Check the preview/highlighter, we should be able to see what we ignored, but it should be highlighted
|
||||||
|
# We only introduce the "modified" content that includes what we ignore so we can prove the newest version also displays
|
||||||
|
# at /preview
|
||||||
res = client.get(url_for("preview_page", uuid="first"))
|
res = client.get(url_for("preview_page", uuid="first"))
|
||||||
|
# We should be able to see what we ignored
|
||||||
# SHOULD BE be in the preview, it was added in set_modified_original_ignore_response()
|
assert b'<div class="ignored">new ignore stuff' in res.data
|
||||||
# and we have "new ignore stuff" in ignore_text
|
|
||||||
# it is only ignored, it is not removed (it will be highlighted too)
|
|
||||||
assert b'new ignore stuff' in res.data
|
|
||||||
|
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
assert b'Deleted' in res.data
|
assert b'Deleted' in res.data
|
||||||
|
|
||||||
# When adding some ignore text, it should not trigger a change, even if something else on that line changes
|
|
||||||
def test_check_global_ignore_text_functionality(client, live_server, measure_memory_usage):
|
def test_check_global_ignore_text_functionality(client, live_server, measure_memory_usage):
|
||||||
#live_server_setup(live_server)
|
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
ignore_text = "XXXXX\r\nYYYYY\r\nZZZZZ"
|
ignore_text = "XXXXX\r\nYYYYY\r\nZZZZZ"
|
||||||
set_original_ignore_response()
|
set_original_ignore_response()
|
||||||
|
|
||||||
@@ -175,7 +174,6 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
|
|||||||
url_for("settings_page"),
|
url_for("settings_page"),
|
||||||
data={
|
data={
|
||||||
"requests-time_between_check-minutes": 180,
|
"requests-time_between_check-minutes": 180,
|
||||||
"application-ignore_whitespace": "y",
|
|
||||||
"application-global_ignore_text": ignore_text,
|
"application-global_ignore_text": ignore_text,
|
||||||
'application-fetch_backend': "html_requests"
|
'application-fetch_backend': "html_requests"
|
||||||
},
|
},
|
||||||
@@ -196,7 +194,9 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
|
|||||||
# 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)
|
||||||
|
|
||||||
#Adding some ignore text should not trigger a change
|
|
||||||
|
# Goto the edit page of the item, add our ignore text
|
||||||
|
# Add our URL to the import page
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("edit_page", uuid="first"),
|
url_for("edit_page", uuid="first"),
|
||||||
data={"ignore_text": "something irrelevent but just to check", "url": test_url, 'fetch_backend': "html_requests"},
|
data={"ignore_text": "something irrelevent but just to check", "url": test_url, 'fetch_backend': "html_requests"},
|
||||||
@@ -212,15 +212,20 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
|
|||||||
|
|
||||||
# Trigger a check
|
# Trigger a check
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
|
|
||||||
|
# Give the thread time to pick it up
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
# It should report nothing found (no new 'unviewed' class), adding random ignore text should not cause a change
|
|
||||||
|
# so that we are sure everything is viewed and in a known 'nothing changed' state
|
||||||
|
res = client.get(url_for("diff_history_page", uuid="first"))
|
||||||
|
|
||||||
|
# It should report nothing found (no new 'unviewed' class)
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("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
|
||||||
#####
|
|
||||||
|
|
||||||
# Make a change which includes the ignore text, it should be ignored and no 'change' triggered
|
|
||||||
# It adds text with "ZZZZzzzz" and "ZZZZ" is in the ignore list
|
# Make a change which includes the ignore text
|
||||||
set_modified_ignore_response()
|
set_modified_ignore_response()
|
||||||
|
|
||||||
# Trigger a check
|
# Trigger a check
|
||||||
@@ -230,7 +235,6 @@ def test_check_global_ignore_text_functionality(client, live_server, measure_mem
|
|||||||
|
|
||||||
# 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("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
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def set_original_ignore_response():
|
|||||||
f.write(test_return_data)
|
f.write(test_return_data)
|
||||||
|
|
||||||
|
|
||||||
def test_ignore(client, live_server, measure_memory_usage):
|
def test_highlight_ignore(client, live_server, measure_memory_usage):
|
||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
set_original_ignore_response()
|
set_original_ignore_response()
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
@@ -51,9 +51,9 @@ def test_ignore(client, live_server, measure_memory_usage):
|
|||||||
# Should return a link
|
# Should return a link
|
||||||
assert b'href' in res.data
|
assert b'href' in res.data
|
||||||
|
|
||||||
# It should not be in the preview anymore
|
# And it should register in the preview page
|
||||||
res = client.get(url_for("preview_page", uuid=uuid))
|
res = client.get(url_for("preview_page", uuid=uuid))
|
||||||
assert b'<div class="ignored">oh yeah 456' not in res.data
|
assert b'<div class="ignored">oh yeah 456' in res.data
|
||||||
|
|
||||||
# Should be in base.html
|
# Should be in base.html
|
||||||
assert b'csrftoken' in res.data
|
assert b'csrftoken' in res.data
|
||||||
@@ -499,7 +499,7 @@ def test_correct_header_detect(client, live_server, measure_memory_usage):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert b'"hello": 123,' in res.data
|
assert b'"hello": 123,' in res.data
|
||||||
assert b'"world": 123' in res.data
|
assert b'"world": 123</div>' 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)
|
||||||
assert b'Deleted' in res.data
|
assert b'Deleted' in res.data
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
from flask import url_for
|
|
||||||
from changedetectionio.tests.util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
|
|
||||||
|
|
||||||
|
|
||||||
def set_response():
|
|
||||||
|
|
||||||
data = f"""<html>
|
|
||||||
<body>Awesome, you made it<br>
|
|
||||||
yeah the socks request worked<br>
|
|
||||||
something to ignore<br>
|
|
||||||
something to trigger<br>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open("test-datastore/endpoint-content.txt", "w") as f:
|
|
||||||
f.write(data)
|
|
||||||
|
|
||||||
def test_content_filter_live_preview(client, live_server, measure_memory_usage):
|
|
||||||
live_server_setup(live_server)
|
|
||||||
set_response()
|
|
||||||
|
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
|
||||||
|
|
||||||
res = client.post(
|
|
||||||
url_for("form_quick_watch_add"),
|
|
||||||
data={"url": test_url, "tags": ''},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
uuid = extract_UUID_from_client(client)
|
|
||||||
res = client.post(
|
|
||||||
url_for("edit_page", uuid=uuid),
|
|
||||||
data={
|
|
||||||
"include_filters": "",
|
|
||||||
"fetch_backend": 'html_requests',
|
|
||||||
"ignore_text": "something to ignore",
|
|
||||||
"trigger_text": "something to trigger",
|
|
||||||
"url": test_url,
|
|
||||||
},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
assert b"Updated watch." in res.data
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
|
|
||||||
# The endpoint is a POST and accepts the form values to override the watch preview
|
|
||||||
import json
|
|
||||||
|
|
||||||
# DEFAULT OUTPUT WITHOUT ANYTHING UPDATED/CHANGED - SHOULD SEE THE WATCH DEFAULTS
|
|
||||||
res = client.post(
|
|
||||||
url_for("watch_get_preview_rendered", uuid=uuid)
|
|
||||||
)
|
|
||||||
default_return = json.loads(res.data.decode('utf-8'))
|
|
||||||
assert default_return.get('after_filter')
|
|
||||||
assert default_return.get('before_filter')
|
|
||||||
assert default_return.get('ignore_line_numbers') == [3] # "something to ignore" line 3
|
|
||||||
assert default_return.get('trigger_line_numbers') == [4] # "something to trigger" line 4
|
|
||||||
|
|
||||||
# SEND AN UPDATE AND WE SHOULD SEE THE OUTPUT CHANGE SO WE KNOW TO HIGHLIGHT NEW STUFF
|
|
||||||
res = client.post(
|
|
||||||
url_for("watch_get_preview_rendered", uuid=uuid),
|
|
||||||
data={
|
|
||||||
"include_filters": "",
|
|
||||||
"fetch_backend": 'html_requests',
|
|
||||||
"ignore_text": "sOckS", # Also be sure case insensitive works
|
|
||||||
"trigger_text": "AweSOme",
|
|
||||||
"url": test_url,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
reply = json.loads(res.data.decode('utf-8'))
|
|
||||||
assert reply.get('after_filter')
|
|
||||||
assert reply.get('before_filter')
|
|
||||||
assert reply.get('ignore_line_numbers') == [2] # Ignored - "socks" on line 2
|
|
||||||
assert reply.get('trigger_line_numbers') == [1] # Triggers "Awesome" in line 1
|
|
||||||
|
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
|
||||||
assert b'Deleted' in res.data
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
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>
|
||||||
@@ -13,16 +11,17 @@ 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):
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import time
|
|
||||||
from flask import url_for
|
|
||||||
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
|
|
||||||
|
|
||||||
|
|
||||||
# `subtractive_selectors` should still work in `source:` type requests
|
|
||||||
def test_fetch_pdf(client, live_server, measure_memory_usage):
|
|
||||||
import shutil
|
|
||||||
shutil.copy("tests/test.pdf", "test-datastore/endpoint-test.pdf")
|
|
||||||
|
|
||||||
live_server_setup(live_server)
|
|
||||||
test_url = url_for('test_pdf_endpoint', _external=True)
|
|
||||||
# Add our URL to the import page
|
|
||||||
res = client.post(
|
|
||||||
url_for("import_page"),
|
|
||||||
data={"urls": test_url},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert b"1 Imported" in res.data
|
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
|
|
||||||
res = client.get(
|
|
||||||
url_for("preview_page", uuid="first"),
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# PDF header should not be there (it was converted to text)
|
|
||||||
assert b'PDF' not in res.data[:10]
|
|
||||||
assert b'hello world' in res.data
|
|
||||||
|
|
||||||
# So we know if the file changes in other ways
|
|
||||||
import hashlib
|
|
||||||
original_md5 = hashlib.md5(open("test-datastore/endpoint-test.pdf", 'rb').read()).hexdigest().upper()
|
|
||||||
# We should have one
|
|
||||||
assert len(original_md5) > 0
|
|
||||||
# And it's going to be in the document
|
|
||||||
assert b'Document checksum - ' + bytes(str(original_md5).encode('utf-8')) in res.data
|
|
||||||
|
|
||||||
shutil.copy("tests/test2.pdf", "test-datastore/endpoint-test.pdf")
|
|
||||||
changed_md5 = hashlib.md5(open("test-datastore/endpoint-test.pdf", 'rb').read()).hexdigest().upper()
|
|
||||||
res = client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
|
||||||
assert b'1 watches queued for rechecking.' in res.data
|
|
||||||
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
|
|
||||||
# Now something should be ready, indicated by having a 'unviewed' class
|
|
||||||
res = client.get(url_for("index"))
|
|
||||||
assert b'unviewed' in res.data
|
|
||||||
|
|
||||||
# The original checksum should be not be here anymore (cdio adds it to the bottom of the text)
|
|
||||||
|
|
||||||
res = client.get(
|
|
||||||
url_for("preview_page", uuid="first"),
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert original_md5.encode('utf-8') not in res.data
|
|
||||||
assert changed_md5.encode('utf-8') in res.data
|
|
||||||
|
|
||||||
res = client.get(
|
|
||||||
url_for("diff_history_page", uuid="first"),
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert original_md5.encode('utf-8') in res.data
|
|
||||||
assert changed_md5.encode('utf-8') in res.data
|
|
||||||
|
|
||||||
assert b'here is a change' in res.data
|
|
||||||
@@ -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, wait_for_notification_endpoint_output
|
from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
|
||||||
from ..notification import default_notification_format
|
from ..notification import default_notification_format
|
||||||
|
|
||||||
instock_props = [
|
instock_props = [
|
||||||
@@ -146,13 +146,14 @@ 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)
|
||||||
@@ -177,9 +178,11 @@ 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)
|
||||||
@@ -194,8 +197,7 @@ 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"))
|
||||||
# Depending on the LOCALE it may be either of these (generally for US/default/etc)
|
assert b'1,890.45' or b'1890.45' in res.data
|
||||||
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)
|
||||||
@@ -360,7 +362,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)
|
||||||
wait_for_notification_endpoint_output()
|
time.sleep(3)
|
||||||
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()
|
||||||
@@ -413,31 +415,3 @@ def test_data_sanity(client, live_server):
|
|||||||
res = client.get(
|
res = client.get(
|
||||||
url_for("edit_page", uuid="first"))
|
url_for("edit_page", uuid="first"))
|
||||||
assert test_url2.encode('utf-8') in res.data
|
assert test_url2.encode('utf-8') in res.data
|
||||||
|
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
|
||||||
assert b'Deleted' in res.data
|
|
||||||
|
|
||||||
# All examples should give a prive of 666.66
|
|
||||||
def test_special_prop_examples(client, live_server):
|
|
||||||
import glob
|
|
||||||
#live_server_setup(live_server)
|
|
||||||
|
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
|
||||||
check_path = os.path.join(os.path.dirname(__file__), "itemprop_test_examples", "*.txt")
|
|
||||||
files = glob.glob(check_path)
|
|
||||||
assert files
|
|
||||||
for test_example_filename in files:
|
|
||||||
with open(test_example_filename, 'r') as example_f:
|
|
||||||
with open("test-datastore/endpoint-content.txt", "w") as test_f:
|
|
||||||
test_f.write(f"<html><body>{example_f.read()}</body></html>")
|
|
||||||
|
|
||||||
# Now fetch it and check the price worked
|
|
||||||
client.post(
|
|
||||||
url_for("form_quick_watch_add"),
|
|
||||||
data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
res = client.get(url_for("index"))
|
|
||||||
assert b'ception' not in res.data
|
|
||||||
assert b'155.55' 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, wait_for_all_checks
|
from . util import live_server_setup
|
||||||
|
|
||||||
|
|
||||||
def set_original_ignore_response():
|
def set_original_ignore_response():
|
||||||
@@ -59,9 +59,12 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
|
|||||||
|
|
||||||
live_server_setup(live_server)
|
live_server_setup(live_server)
|
||||||
|
|
||||||
|
sleep_time_for_fetch_thread = 3
|
||||||
trigger_text = "Add to cart"
|
trigger_text = "Add to cart"
|
||||||
set_original_ignore_response()
|
set_original_ignore_response()
|
||||||
|
|
||||||
|
# Give the endpoint time to spin up
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# Add our URL to the import page
|
# Add our URL to the import page
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
@@ -86,14 +89,14 @@ def test_trigger_functionality(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)
|
|
||||||
# Check it saved
|
# Check it saved
|
||||||
res = client.get(
|
res = client.get(
|
||||||
url_for("edit_page", uuid="first"),
|
url_for("edit_page", uuid="first"),
|
||||||
)
|
)
|
||||||
assert bytes(trigger_text.encode('utf-8')) in res.data
|
assert bytes(trigger_text.encode('utf-8')) in res.data
|
||||||
|
|
||||||
|
# Give the thread time to pick it up
|
||||||
|
time.sleep(sleep_time_for_fetch_thread)
|
||||||
|
|
||||||
# so that we set the state to 'unviewed' after all the edits
|
# so that we set the state to 'unviewed' after all the edits
|
||||||
client.get(url_for("diff_history_page", uuid="first"))
|
client.get(url_for("diff_history_page", uuid="first"))
|
||||||
@@ -101,7 +104,8 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
|
|||||||
# Trigger a check
|
# Trigger a check
|
||||||
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)
|
# Give the thread time to pick it up
|
||||||
|
time.sleep(sleep_time_for_fetch_thread)
|
||||||
|
|
||||||
# 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("index"))
|
||||||
@@ -113,17 +117,19 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
|
|||||||
|
|
||||||
# Trigger a check
|
# Trigger a check
|
||||||
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)
|
# Give the thread time to pick it up
|
||||||
|
time.sleep(sleep_time_for_fetch_thread)
|
||||||
|
|
||||||
# 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("index"))
|
||||||
assert b'unviewed' not in res.data
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
# Now set the content which contains the trigger text
|
# Now set the content which contains the trigger text
|
||||||
|
time.sleep(sleep_time_for_fetch_thread)
|
||||||
set_modified_with_trigger_text_response()
|
set_modified_with_trigger_text_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)
|
time.sleep(sleep_time_for_fetch_thread)
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("index"))
|
||||||
assert b'unviewed' in res.data
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
@@ -136,7 +142,4 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
|
|||||||
res = client.get(url_for("preview_page", uuid="first"))
|
res = client.get(url_for("preview_page", uuid="first"))
|
||||||
|
|
||||||
# We should be able to see what we triggered on
|
# We should be able to see what we triggered on
|
||||||
# The JS highlighter should tell us which lines (also used in the live-preview)
|
assert b'<div class="triggered">Add to cart' in res.data
|
||||||
assert b'const triggered_line_numbers = [6]' in res.data
|
|
||||||
assert b'Add to cart' in res.data
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ def set_original_ignore_response():
|
|||||||
<p>Some initial text</p>
|
<p>Some initial text</p>
|
||||||
<p>Which is across multiple lines</p>
|
<p>Which is across multiple lines</p>
|
||||||
<p>So let's see what happens.</p>
|
<p>So let's see what happens.</p>
|
||||||
<p> So let's see what happens. <br> </p>
|
|
||||||
<p>A - sortable line</p>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
@@ -168,50 +166,3 @@ def test_sort_lines_functionality(client, live_server, measure_memory_usage):
|
|||||||
|
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
assert b'Deleted' in res.data
|
assert b'Deleted' in res.data
|
||||||
|
|
||||||
|
|
||||||
def test_extra_filters(client, live_server, measure_memory_usage):
|
|
||||||
#live_server_setup(live_server)
|
|
||||||
|
|
||||||
set_original_ignore_response()
|
|
||||||
|
|
||||||
# Add our URL to the import page
|
|
||||||
test_url = url_for('test_endpoint', _external=True)
|
|
||||||
res = client.post(
|
|
||||||
url_for("import_page"),
|
|
||||||
data={"urls": test_url},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
assert b"1 Imported" in res.data
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
|
|
||||||
# Add our URL to the import page
|
|
||||||
res = client.post(
|
|
||||||
url_for("edit_page", uuid="first"),
|
|
||||||
data={"remove_duplicate_lines": "y",
|
|
||||||
"trim_text_whitespace": "y",
|
|
||||||
"sort_text_alphabetically": "", # leave this OFF for testing
|
|
||||||
"url": test_url,
|
|
||||||
"fetch_backend": "html_requests"},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
assert b"Updated watch." in res.data
|
|
||||||
# Give the thread time to pick it up
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
# Trigger a check
|
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
|
||||||
|
|
||||||
# Give the thread time to pick it up
|
|
||||||
wait_for_all_checks(client)
|
|
||||||
|
|
||||||
res = client.get(
|
|
||||||
url_for("preview_page", uuid="first")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert res.data.count(b"see what happens.") == 1
|
|
||||||
|
|
||||||
# still should remain unsorted ('A - sortable line') stays at the end
|
|
||||||
assert res.data.find(b'A - sortable line') > res.data.find(b'Which is across multiple lines')
|
|
||||||
|
|
||||||
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
|
||||||
assert b'Deleted' in res.data
|
|
||||||
@@ -161,8 +161,8 @@ def test_check_xpath_text_function_utf8(client, live_server, measure_memory_usag
|
|||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
assert b'Stock Alert (UK): RPi CM4' in res.data
|
assert b'<div class="">Stock Alert (UK): RPi CM4' in res.data
|
||||||
assert b'Stock Alert (UK): Big monitor' in res.data
|
assert b'<div class="">Stock Alert (UK): Big monitor' 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)
|
||||||
assert b'Deleted' in res.data
|
assert b'Deleted' in res.data
|
||||||
|
|||||||
@@ -18,13 +18,12 @@ class TestDiffBuilder(unittest.TestCase):
|
|||||||
|
|
||||||
watch['last_viewed'] = 110
|
watch['last_viewed'] = 110
|
||||||
|
|
||||||
# Contents from the browser are always returned from the browser/requests/etc as str, str is basically UTF-16 in python
|
watch.save_history_text(contents=b"hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4()))
|
||||||
watch.save_history_text(contents="hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4()))
|
watch.save_history_text(contents=b"hello world", timestamp=105, snapshot_id=str(uuid_builder.uuid4()))
|
||||||
watch.save_history_text(contents="hello world", timestamp=105, snapshot_id=str(uuid_builder.uuid4()))
|
watch.save_history_text(contents=b"hello world", timestamp=109, snapshot_id=str(uuid_builder.uuid4()))
|
||||||
watch.save_history_text(contents="hello world", timestamp=109, snapshot_id=str(uuid_builder.uuid4()))
|
watch.save_history_text(contents=b"hello world", timestamp=112, snapshot_id=str(uuid_builder.uuid4()))
|
||||||
watch.save_history_text(contents="hello world", timestamp=112, snapshot_id=str(uuid_builder.uuid4()))
|
watch.save_history_text(contents=b"hello world", timestamp=115, snapshot_id=str(uuid_builder.uuid4()))
|
||||||
watch.save_history_text(contents="hello world", timestamp=115, snapshot_id=str(uuid_builder.uuid4()))
|
watch.save_history_text(contents=b"hello world", timestamp=117, snapshot_id=str(uuid_builder.uuid4()))
|
||||||
watch.save_history_text(contents="hello world", timestamp=117, snapshot_id=str(uuid_builder.uuid4()))
|
|
||||||
|
|
||||||
p = watch.get_next_snapshot_key_to_last_viewed
|
p = watch.get_next_snapshot_key_to_last_viewed
|
||||||
assert p == "112", "Correct last-viewed timestamp was detected"
|
assert p == "112", "Correct last-viewed timestamp was detected"
|
||||||
|
|||||||
@@ -76,18 +76,6 @@ def set_more_modified_response():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def wait_for_notification_endpoint_output():
|
|
||||||
'''Apprise can take a few seconds to fire'''
|
|
||||||
#@todo - could check the apprise object directly instead of looking for this file
|
|
||||||
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,9 +189,7 @@ class update_worker(threading.Thread):
|
|||||||
'screenshot': None
|
'screenshot': None
|
||||||
})
|
})
|
||||||
self.notification_q.put(n_object)
|
self.notification_q.put(n_object)
|
||||||
logger.debug(f"Sent filter not found notification for {watch_uuid}")
|
logger.error(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)
|
||||||
@@ -260,6 +258,9 @@ class update_worker(threading.Thread):
|
|||||||
try:
|
try:
|
||||||
# Processor is what we are using for detecting the "Change"
|
# Processor is what we are using for detecting the "Change"
|
||||||
processor = watch.get('processor', 'text_json_diff')
|
processor = watch.get('processor', 'text_json_diff')
|
||||||
|
# Abort processing when the content was the same as the last fetch
|
||||||
|
skip_when_same_checksum = queued_item_data.item.get('skip_when_checksum_same')
|
||||||
|
|
||||||
|
|
||||||
# Init a new 'difference_detection_processor', first look in processors
|
# Init a new 'difference_detection_processor', first look in processors
|
||||||
processor_module_name = f"changedetectionio.processors.{processor}.processor"
|
processor_module_name = f"changedetectionio.processors.{processor}.processor"
|
||||||
@@ -275,13 +276,16 @@ class update_worker(threading.Thread):
|
|||||||
|
|
||||||
update_handler.call_browser()
|
update_handler.call_browser()
|
||||||
|
|
||||||
changed_detected, update_obj, contents = update_handler.run_changedetection(watch=watch)
|
changed_detected, update_obj, contents = update_handler.run_changedetection(
|
||||||
|
watch=watch,
|
||||||
|
skip_when_checksum_same=skip_when_same_checksum,
|
||||||
|
)
|
||||||
|
|
||||||
# Re #342
|
# Re #342
|
||||||
# In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes.
|
# In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes.
|
||||||
# We then convert/.decode('utf-8') for the notification etc
|
# We then convert/.decode('utf-8') for the notification etc
|
||||||
# if not isinstance(contents, (bytes, bytearray)):
|
if not isinstance(contents, (bytes, bytearray)):
|
||||||
# raise Exception("Error - returned data from the fetch handler SHOULD be bytes")
|
raise Exception("Error - returned data from the fetch handler SHOULD be bytes")
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
logger.critical(f"File permission error updating file, watch: {uuid}")
|
logger.critical(f"File permission error updating file, watch: {uuid}")
|
||||||
logger.critical(str(e))
|
logger.critical(str(e))
|
||||||
@@ -332,8 +336,7 @@ class update_worker(threading.Thread):
|
|||||||
elif e.status_code == 500:
|
elif e.status_code == 500:
|
||||||
err_text = "Error - 500 (Internal server error) received from the web site"
|
err_text = "Error - 500 (Internal server error) received from the web site"
|
||||||
else:
|
else:
|
||||||
extra = ' (Access denied or blocked)' if str(e.status_code).startswith('4') else ''
|
err_text = "Error - Request returned a HTTP error code {}".format(str(e.status_code))
|
||||||
err_text = f"Error - Request returned a HTTP error code {e.status_code}{extra}"
|
|
||||||
|
|
||||||
if e.screenshot:
|
if e.screenshot:
|
||||||
watch.save_screenshot(screenshot=e.screenshot, as_error=True)
|
watch.save_screenshot(screenshot=e.screenshot, as_error=True)
|
||||||
@@ -361,22 +364,18 @@ 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', 0)
|
c = watch.get('consecutive_filter_failures', 5)
|
||||||
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', 0)
|
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts',
|
||||||
logger.debug(f"Filter for {uuid} not found, consecutive_filter_failures: {c} of threshold {threshold}")
|
0)
|
||||||
if c >= threshold:
|
logger.warning(f"Filter for {uuid} not found, consecutive_filter_failures: {c}")
|
||||||
|
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
|
||||||
|
|
||||||
@@ -423,7 +422,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', 0)
|
c = watch.get('consecutive_filter_failures', 5)
|
||||||
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',
|
||||||
@@ -486,8 +485,6 @@ class update_worker(threading.Thread):
|
|||||||
if not self.datastore.data['watching'].get(uuid):
|
if not self.datastore.data['watching'].get(uuid):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
update_obj['content-type'] = update_handler.fetcher.get_all_headers().get('content-type', '').lower()
|
|
||||||
|
|
||||||
# Mark that we never had any failures
|
# Mark that we never had any failures
|
||||||
if not watch.get('ignore_status_codes'):
|
if not watch.get('ignore_status_codes'):
|
||||||
update_obj['consecutive_filter_failures'] = 0
|
update_obj['consecutive_filter_failures'] = 0
|
||||||
|
|||||||
@@ -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=TRACE
|
# - LOGGER_LEVEL=DEBUG
|
||||||
#
|
#
|
||||||
# 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,9 +29,8 @@ 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 target "Chrome" Playwright URL, do not use "'s or 's!
|
# Alternative Playwright URL, do not use "'s or 's!
|
||||||
# "Playwright" is a driver/librarythat allows changedetection to talk to a Chrome or similar browser.
|
# - PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000
|
||||||
# - 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
|
||||||
#
|
#
|
||||||
@@ -58,10 +57,6 @@ services:
|
|||||||
#
|
#
|
||||||
# Absolute minimum seconds to recheck, overrides any watch minimum, change to 0 to disable
|
# Absolute minimum seconds to recheck, overrides any watch minimum, change to 0 to disable
|
||||||
# - MINIMUM_SECONDS_RECHECK_TIME=3
|
# - MINIMUM_SECONDS_RECHECK_TIME=3
|
||||||
#
|
|
||||||
# If you want to watch local files file:///path/to/file.txt (careful! security implications!)
|
|
||||||
# - ALLOW_FILE_URI=False
|
|
||||||
|
|
||||||
# Comment out ports: when using behind a reverse proxy , enable networks: etc.
|
# Comment out ports: when using behind a reverse proxy , enable networks: etc.
|
||||||
ports:
|
ports:
|
||||||
- 5000:5000
|
- 5000:5000
|
||||||
@@ -78,10 +73,10 @@ services:
|
|||||||
# condition: service_started
|
# condition: service_started
|
||||||
|
|
||||||
|
|
||||||
# Sockpuppetbrowser is basically chrome wrapped in an API for allowing fast fetching of web-pages.
|
# Used for fetching pages via Playwright+Chrome where you need Javascript support.
|
||||||
# RECOMMENDED FOR FETCHING PAGES WITH CHROME
|
# RECOMMENDED FOR FETCHING PAGES WITH CHROME
|
||||||
# sockpuppetbrowser:
|
# playwright-chrome:
|
||||||
# hostname: sockpuppetbrowser
|
# hostname: playwright-chrome
|
||||||
# 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.9.0
|
apprise~=1.8.1
|
||||||
|
|
||||||
# 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,9 +79,8 @@ pyppeteerstealth>=0.0.4
|
|||||||
pytest ~=7.2
|
pytest ~=7.2
|
||||||
pytest-flask ~=1.2
|
pytest-flask ~=1.2
|
||||||
|
|
||||||
# Anything 4.0 and up but not 5.0
|
# Pin jsonschema version to prevent build errors on armv6 while rpds-py wheels aren't available (1708)
|
||||||
jsonschema ~= 4.0
|
jsonschema==4.17.3
|
||||||
|
|
||||||
|
|
||||||
loguru
|
loguru
|
||||||
|
|
||||||
@@ -93,5 +92,3 @@ babel
|
|||||||
|
|
||||||
# Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096
|
# Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096
|
||||||
greenlet >= 3.0.3
|
greenlet >= 3.0.3
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user