Compare commits

..

1 Commits

Author SHA1 Message Date
dgtlmoon
fd607a65ad Dropping older ARM v6 support due to dependencies not having support 2024-07-18 10:18:09 +02:00
114 changed files with 630 additions and 1774 deletions

View File

@@ -95,7 +95,7 @@ jobs:
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:dev,ghcr.io/${{ github.repository }}:dev
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -116,7 +116,7 @@ jobs:
ghcr.io/dgtlmoon/changedetection.io:${{ github.event.release.tag_name }}
${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest
ghcr.io/dgtlmoon/changedetection.io:latest
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
cache-from: type=gha
cache-to: type=gha,mode=max
# Looks like this was disabled

View File

@@ -64,7 +64,7 @@ jobs:
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache

View File

@@ -43,7 +43,7 @@ Requires Playwright to be enabled.
### Awesome restock and price change notifications
Enable the _"Re-stock & Price detection for single product pages"_ option to activate the best way to monitor product pricing, this will extract any meta-data in the HTML page and give you many options to follow the pricing of the product.
Enable the _"Re-stock & Price detection for single product pages"_ option to activate the best way to monitor product pricing.
Easily organise and monitor prices for products from the dashboard, get alerts and notifications when the price of a product changes or comes back in stock again!

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
# Only exists for direct CLI usage

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3
#!/usr/bin/python3
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.46.04'
__version__ = '0.45.26'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError

View File

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

View File

@@ -85,8 +85,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
browsersteps_start_session['browserstepper'] = browser_steps.browsersteps_live_ui(
playwright_browser=browsersteps_start_session['browser'],
proxy=proxy,
start_url=datastore.data['watching'][watch_uuid].get('url'),
headers=datastore.data['watching'][watch_uuid].get('headers')
start_url=datastore.data['watching'][watch_uuid].get('url')
)
# For test

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import os
import time
@@ -25,7 +25,6 @@ browser_step_ui_config = {'Choose one': '0 0',
'Click element if exists': '1 0',
'Click element': '1 0',
'Click element containing text': '0 1',
'Click element containing text if exists': '0 1',
'Enter text in field': '1 1',
'Execute JS': '0 1',
# '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)
def action_click_element_containing_text(self, selector=None, value=''):
logger.debug("Clicking element containing text")
if not len(value.strip()):
return
elem = self.page.get_by_text(value)
if elem.count():
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):
if not len(selector.strip()):
return

View File

@@ -58,9 +58,9 @@ xpath://body/div/span[contains(@class, 'example-class')]",
{% if '/text()' in field %}
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the &lt;element&gt; contains &lt;![CDATA[]]&gt;</strong></span><br>
{% endif %}
<span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
<div data-target="#advanced-help-selectors" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div>
<ul id="advanced-help-selectors">
<span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br>
<ul>
<li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
<li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed).
<ul>
@@ -89,13 +89,11 @@ xpath://body/div/span[contains(@class, 'example-class')]",
{{ render_field(form.subtractive_selectors, rows=5, placeholder="header
footer
nav
.stockticker
//*[contains(text(), 'Advertisement')]") }}
.stockticker") }}
<span class="pure-form-message-inline">
<ul>
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
<li> Remove HTML element(s) by CSS selector before text conversion. </li>
<li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
</ul>
</span>
</fieldset>

View File

@@ -4,9 +4,7 @@ from loguru import logger
from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException
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,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'
# available_fetchers() will scan this implementation looking for anything starting with html_
# this information is used in the form selections

View File

@@ -65,8 +65,8 @@ class Fetcher():
def __init__(self):
import importlib.resources
self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')
self.xpath_element_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text()
self.instock_data_js = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text()
@abstractmethod
def get_error(self):
@@ -81,8 +81,7 @@ class Fetcher():
request_method,
ignore_status_codes=False,
current_include_filters=None,
is_binary=False,
empty_pages_are_a_change=False):
is_binary=False):
# Should set self.error, self.status_code and self.content
pass

View File

@@ -83,8 +83,7 @@ class fetcher(Fetcher):
request_method,
ignore_status_codes=False,
current_include_filters=None,
is_binary=False,
empty_pages_are_a_change=False):
is_binary=False):
from playwright.sync_api import sync_playwright
import playwright._impl._errors
@@ -131,7 +130,7 @@ class fetcher(Fetcher):
if response is None:
context.close()
browser.close()
logger.debug("Content Fetcher > Response object from the browser communication was none")
logger.debug("Content Fetcher > Response object was none")
raise EmptyReply(url=url, status_code=None)
try:
@@ -167,10 +166,10 @@ class fetcher(Fetcher):
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
if not empty_pages_are_a_change and len(self.page.content().strip()) == 0:
logger.debug("Content Fetcher > Content was empty, empty_pages_are_a_change = False")
if len(self.page.content().strip()) == 0:
context.close()
browser.close()
logger.debug("Content Fetcher > Content was empty")
raise EmptyReply(url=url, status_code=response.status)
# Run Browser Steps here

View File

@@ -75,8 +75,7 @@ class fetcher(Fetcher):
request_method,
ignore_status_codes,
current_include_filters,
is_binary,
empty_pages_are_a_change
is_binary
):
from changedetectionio.content_fetchers import visualselector_xpath_selectors
@@ -154,7 +153,7 @@ class fetcher(Fetcher):
if response is None:
await self.page.close()
await browser.close()
logger.warning("Content Fetcher > Response object was none (as in, the response from the browser was empty, not just the content)")
logger.warning("Content Fetcher > Response object was none")
raise EmptyReply(url=url, status_code=None)
self.headers = response.headers
@@ -187,11 +186,10 @@ class fetcher(Fetcher):
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
content = await self.page.content
if not empty_pages_are_a_change and len(content.strip()) == 0:
logger.error("Content Fetcher > Content was empty (empty_pages_are_a_change is False), closing browsers")
if len(content.strip()) == 0:
await self.page.close()
await browser.close()
logger.error("Content Fetcher > Content was empty")
raise EmptyReply(url=url, status_code=response.status)
# Run Browser Steps here
@@ -249,7 +247,7 @@ class fetcher(Fetcher):
await self.fetch_page(**kwargs)
def run(self, url, timeout, request_headers, request_body, request_method, ignore_status_codes=False,
current_include_filters=None, is_binary=False, empty_pages_are_a_change=False):
current_include_filters=None, is_binary=False):
#@todo make update_worker async which could run any of these content_fetchers within memory and time constraints
max_time = os.getenv('PUPPETEER_MAX_PROCESSING_TIMEOUT_SECONDS', 180)
@@ -264,8 +262,7 @@ class fetcher(Fetcher):
request_method=request_method,
ignore_status_codes=ignore_status_codes,
current_include_filters=current_include_filters,
is_binary=is_binary,
empty_pages_are_a_change=empty_pages_are_a_change
is_binary=is_binary
), timeout=max_time))
except asyncio.TimeoutError:
raise(BrowserFetchTimedOut(msg=f"Browser connected but was unable to process the page in {max_time} seconds."))

View File

@@ -1,7 +1,9 @@
from loguru import logger
import hashlib
import os
from changedetectionio import strtobool
import chardet
import requests
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
from changedetectionio.content_fetchers.base import Fetcher
@@ -23,11 +25,7 @@ class fetcher(Fetcher):
request_method,
ignore_status_codes=False,
current_include_filters=None,
is_binary=False,
empty_pages_are_a_change=False):
import chardet
import requests
is_binary=False):
if self.browser_steps_get_valid_steps():
raise BrowserStepsInUnsupportedFetcher(url=url)
@@ -47,19 +45,13 @@ class fetcher(Fetcher):
if self.system_https_proxy:
proxies['https'] = self.system_https_proxy
session = requests.Session()
if strtobool(os.getenv('ALLOW_FILE_URI', 'false')) and url.startswith('file://'):
from requests_file import FileAdapter
session.mount('file://', FileAdapter())
r = session.request(method=request_method,
data=request_body.encode('utf-8') if type(request_body) is str else request_body,
url=url,
headers=request_headers,
timeout=timeout,
proxies=proxies,
verify=False)
r = requests.request(method=request_method,
data=request_body,
url=url,
headers=request_headers,
timeout=timeout,
proxies=proxies,
verify=False)
# If the response did not tell us what encoding format to expect, Then use chardet to override what `requests` thinks.
# For example - some sites don't tell us it's utf-8, but return utf-8 content
@@ -75,10 +67,7 @@ class fetcher(Fetcher):
self.headers = r.headers
if not r.content or not len(r.content):
if not empty_pages_are_a_change:
raise EmptyReply(url=url, status_code=r.status_code)
else:
logger.debug(f"URL {url} gave zero byte content reply with Status Code {r.status_code}, but empty_pages_are_a_change = True")
raise EmptyReply(url=url, status_code=r.status_code)
# @todo test this
# @todo maybe you really want to test zero-byte return pages?

View File

@@ -75,7 +75,6 @@ function isItemInStock() {
'vergriffen',
'vorbestellen',
'vorbestellung ist bald möglich',
'we don\'t currently have any',
'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 don\'t know when or if this item will be back in stock.',
@@ -154,14 +153,10 @@ function isItemInStock() {
}
elementText = "";
try {
if (element.tagName.toLowerCase() === "input") {
elementText = element.value.toLowerCase().trim();
} else {
elementText = getElementBaseText(element);
}
} catch (e) {
console.warn('stock-not-in-stock.js scraper - handling element for gettext failed', e);
if (element.tagName.toLowerCase() === "input") {
elementText = element.value.toLowerCase().trim();
} else {
elementText = getElementBaseText(element);
}
if (elementText.length) {
@@ -178,8 +173,7 @@ function isItemInStock() {
const element = elementsToScan[i];
// 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
// Note: theres also an automated test that places the 'out of stock' text fairly low down
if (element.getBoundingClientRect().top + window.scrollY >= vh + 250 || element.getBoundingClientRect().top + window.scrollY <= 100) {
if (element.getBoundingClientRect().top + window.scrollY >= vh + 150 || element.getBoundingClientRect().top + window.scrollY <= 100) {
continue
}
elementText = "";
@@ -193,7 +187,7 @@ function isItemInStock() {
// and these mean its out of stock
for (const outOfStockText of outOfStockTexts) {
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
}
}

View File

@@ -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({
xpath: xpath_result,
@@ -180,16 +171,9 @@ visibleElementsArray.forEach(function (element) {
height: Math.round(bbox['height']),
left: Math.floor(bbox['left']),
top: Math.floor(bbox['top']) + scroll_y,
// tagName used by Browser Steps
tagName: (element.tagName) ? element.tagName.toLowerCase() : '',
// tagtype used by Browser Steps
tagtype: (element.tagName.toLowerCase() === 'input' && element.type) ? element.type.toLowerCase() : '',
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,
isClickable: window.getComputedStyle(element).cursor == "pointer"
});
});
@@ -230,7 +214,7 @@ if (include_filters.length) {
console.log(e);
}
if (results != null && results.length) {
if (results.length) {
// Iterate over the results
results.forEach(node => {

View File

@@ -56,8 +56,7 @@ class fetcher(Fetcher):
request_method,
ignore_status_codes=False,
current_include_filters=None,
is_binary=False,
empty_pages_are_a_change=False):
is_binary=False):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as ChromeOptions

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import datetime
import flask_login
import locale
import os
@@ -533,22 +532,12 @@ def changedetection_app(config=None, datastore_o=None):
@login_optionally_required
def ajax_callback_send_notification_test(watch_uuid=None):
# Watch_uuid could be unset in the case its used in tag editor, global setings
# Watch_uuid could be unsuet in the case its used in tag editor, global setings
import apprise
import random
from .apprise_asset import 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_group_settings_form = request.args.get('mode', '') == 'group-settings'
# Use an existing random one on the global/main settings form
if not watch_uuid and (is_global_settings_form or is_group_settings_form):
logger.debug(f"Send test notification - Choosing random Watch {watch_uuid}")
watch_uuid = random.choice(list(datastore.data['watching'].keys()))
watch = datastore.data['watching'].get(watch_uuid)
watch = datastore.data['watching'].get(watch_uuid) if watch_uuid else None
notification_urls = request.form['notification_urls'].strip().splitlines()
@@ -560,6 +549,8 @@ def changedetection_app(config=None, datastore_o=None):
tag = datastore.tag_exists_by_name(k.strip())
notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None
is_global_settings_form = request.args.get('mode', '') == 'global-settings'
is_group_settings_form = request.args.get('mode', '') == 'group-settings'
if not notification_urls and not is_global_settings_form and not is_group_settings_form:
# In the global settings, use only what is typed currently in the text box
logger.debug("Test notification - Trying by global system settings notifications")
@@ -578,7 +569,7 @@ def changedetection_app(config=None, datastore_o=None):
try:
# use the same as when it is triggered, but then override it with the form test values
n_object = {
'watch_url': request.form.get('window_url', "https://changedetection.io"),
'watch_url': request.form['window_url'],
'notification_urls': notification_urls
}
@@ -1155,6 +1146,8 @@ def changedetection_app(config=None, datastore_o=None):
@login_optionally_required
def preview_page(uuid):
content = []
ignored_line_numbers = []
trigger_line_numbers = []
versions = []
timestamp = None
@@ -1171,10 +1164,11 @@ def changedetection_app(config=None, datastore_o=None):
system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver'
extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')]
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_'):
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()):
flash("Preview unavailable - No fetch/check completed or triggers not reached", "error")
else:
@@ -1187,12 +1181,31 @@ def changedetection_app(config=None, datastore_o=None):
try:
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,
wordlist=watch['trigger_text'],
mode='line numbers'
)
# 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'],
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:
content.append({'line': f"File doesnt exist or unable to read timestamp {timestamp}", 'classes': ''})
@@ -1203,7 +1216,8 @@ def changedetection_app(config=None, datastore_o=None):
history_n=watch.history_n,
extra_stylesheets=extra_stylesheets,
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'],
screenshot=watch.get_screenshot(),
watch=watch,
@@ -1348,41 +1362,6 @@ def changedetection_app(config=None, datastore_o=None):
except FileNotFoundError:
abort(404)
@app.route("/edit/<string:uuid>/get-html", methods=['GET'])
@login_optionally_required
def watch_get_latest_html(uuid):
from io import BytesIO
from flask import send_file
import brotli
watch = datastore.data['watching'].get(uuid)
if watch and watch.history.keys() and os.path.isdir(watch.watch_data_dir):
latest_filename = list(watch.history.keys())[-1]
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'):
# Read and decompress the Brotli file
decompressed_data = brotli.decompress(f.read())
else:
decompressed_data = f.read()
buffer = BytesIO(decompressed_data)
return send_file(buffer, as_attachment=True, download_name=f"{latest_filename}.html", mimetype='text/html')
# Return a 500 error
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'])
@login_optionally_required
def form_quick_watch_add():

View File

@@ -1,6 +1,5 @@
import os
import re
from loguru import logger
from changedetectionio.strtobool import strtobool
@@ -222,8 +221,7 @@ class ValidateAppRiseServers(object):
def __call__(self, form, field):
import 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:
if not apobj.add(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='')
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()])
title = StringField('Title', default='')
ignore_text = StringListField('Remove lines containing', [ValidateListRegex()])
ignore_text = StringListField('Ignore text', [ValidateListRegex()])
headers = StringDictKeyValue('Request headers')
body = TextAreaField('Request body', [validators.Optional()])
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)
check_unique_lines = BooleanField('Only trigger when unique lines appear in all history', default=False)
remove_duplicate_lines = BooleanField('Remove duplicate lines of text', default=False)
check_unique_lines = BooleanField('Only trigger when unique lines appear', 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_replaced = BooleanField('Replaced/changed lines', default=True)
@@ -526,16 +522,9 @@ class processor_text_json_diff_form(commonSettingsForm):
try:
from changedetectionio.safe_jinja import render as jinja_render
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:
logger.error(e)
self.url.errors.append('Invalid template syntax')
result = False
return result
class SingleExtraProxy(Form):
@@ -586,7 +575,7 @@ class globalSettingsApplicationForm(commonSettingsForm):
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()])
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')
password = SaltyPasswordField()
pager_size = IntegerField('Pager size',

View File

@@ -1,5 +1,10 @@
from bs4 import BeautifulSoup
from inscriptis import get_text
from jsonpath_ng.ext import parse
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 re
@@ -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
def include_filters(include_filters, html_content, append_pretty_line_formatting=False):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html_content, "html.parser")
html_block = ""
r = soup.select(include_filters, separator="")
@@ -52,32 +56,16 @@ def include_filters(include_filters, html_content, append_pretty_line_formatting
return html_block
def subtractive_css_selector(css_selector, html_content):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html_content, "html.parser")
for item in soup.select(css_selector):
item.decompose()
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):
"""Removes elements that match a list of CSS or xPath selectors."""
modified_html = html_content
for selector in selectors:
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
"""Joins individual filters into one css filter."""
selector = ",".join(selectors)
return subtractive_css_selector(selector, html_content)
def elementpath_tostring(obj):
"""
@@ -193,7 +181,6 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals
# Extract/find element
def extract_element(find='title', html_content=''):
from bs4 import BeautifulSoup
#Re #106, be sure to handle when its not found
element_text = None
@@ -207,8 +194,6 @@ def extract_element(find='title', html_content=''):
#
def _parse_json(json_data, json_filter):
from jsonpath_ng.ext import parse
if json_filter.startswith("json:"):
jsonpath_expression = parse(json_filter.replace('json:', ''))
match = jsonpath_expression.find(json_data)
@@ -257,8 +242,6 @@ def _get_stripped_text_from_json_match(match):
# 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)
def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None):
from bs4 import BeautifulSoup
stripped_text_from_html = False
# 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
@@ -369,7 +352,6 @@ def strip_ignore_text(content, wordlist, mode="content"):
return "\n".encode('utf8').join(output)
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*)*)\]\]>'
def repl(m):
text = m.group(1)
@@ -378,9 +360,6 @@ def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False
return re.sub(pattern, repl, html_content)
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
rendering anchor tag content is enable, anchor tag content are also
included in the text

View File

@@ -36,9 +36,8 @@ class model(watch_base):
jitter_seconds = 0
def __init__(self, *arg, **kw):
self.__datastore_path = kw.get('datastore_path')
if kw.get('datastore_path'):
del kw['datastore_path']
self.__datastore_path = kw['datastore_path']
del kw['datastore_path']
super(model, self).__init__(*arg, **kw)
if kw.get('default'):
self.update(kw['default'])
@@ -172,10 +171,6 @@ class model(watch_base):
"""
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
fname = os.path.join(self.watch_data_dir, "history.txt")
if os.path.isfile(fname):
@@ -401,8 +396,8 @@ class model(watch_base):
@property
def watch_data_dir(self):
# 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):
"""Return the text saved from a previous request that resulted in a non-200 error"""
fname = os.path.join(self.watch_data_dir, "last-error.txt")

View File

@@ -18,7 +18,6 @@ class watch_base(dict):
'check_count': 0,
'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.
'content-type': None,
'date_created': None,
'extract_text': [], # Extract text by regex after filters
'extract_title_as_title': False,
@@ -61,8 +60,6 @@ class watch_base(dict):
'time_between_check_use_default': True,
'title': 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
'url': '',
'uuid': str(uuid.uuid4()),

View File

@@ -1,10 +1,9 @@
import apprise
import time
from apprise import NotifyFormat
import apprise
import json
from loguru import logger
valid_tokens = {
'base_url': '',
'current_snapshot': '',
@@ -35,11 +34,86 @@ valid_notification_formats = {
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,
headers=headers,
params=params
)
def process_notification(n_object, datastore):
# so that the custom endpoints are registered
from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
from .safe_jinja import render as jinja_render
now = time.time()
@@ -83,7 +157,7 @@ def process_notification(n_object, datastore):
logger.warning(f"Process Notification: skipping empty notification URL.")
continue
logger.info(f">> Process Notification: AppRise notifying {url}")
logger.info(">> Process Notification: AppRise notifying {}".format(url))
url = jinja_render(template_str=url, **notification_parameters)
# Re 323 - Limit discord length to their 2000 char limit total or it wont send.
@@ -156,7 +230,6 @@ def process_notification(n_object, datastore):
log_value = logs.getvalue()
if log_value and 'WARNING' in log_value or 'ERROR' in log_value:
logger.critical(log_value)
raise Exception(log_value)
# Return what was sent for better logging - after the for loop

View File

@@ -1,14 +1,14 @@
from abc import abstractmethod
from changedetectionio.content_fetchers.base import Fetcher
from changedetectionio.strtobool import strtobool
from copy import deepcopy
from loguru import logger
import hashlib
import importlib
import inspect
import os
import pkgutil
import re
import importlib
import pkgutil
import inspect
class difference_detection_processor():
@@ -23,12 +23,9 @@ class difference_detection_processor():
super().__init__(*args, **kwargs)
self.datastore = datastore
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):
from requests.structures import CaseInsensitiveDict
# Protect against file:// access
if re.search(r'^file://', self.watch.get('url', '').strip(), re.IGNORECASE):
if not strtobool(os.getenv('ALLOW_FILE_URI', 'false')):
@@ -136,18 +133,8 @@ class difference_detection_processor():
is_binary = self.watch.is_pdf
# And here we go! call the right browser with browser-specific settings
empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False)
self.fetcher.run(url=url,
timeout=timeout,
request_headers=request_headers,
request_body=request_body,
request_method=request_method,
ignore_status_codes=ignore_status_codes,
current_include_filters=self.watch.get('include_filters'),
is_binary=is_binary,
empty_pages_are_a_change=empty_pages_are_a_change
)
self.fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_codes, self.watch.get('include_filters'),
is_binary=is_binary)
#@todo .quit here could go on close object, so we can run JS if change-detected
self.fetcher.quit()
@@ -155,7 +142,7 @@ class difference_detection_processor():
# After init, call run_changedetection() which will do the actual change-detection
@abstractmethod
def run_changedetection(self, watch, skip_when_checksum_same: bool = True):
def run_changedetection(self, watch, skip_when_checksum_same=True):
update_obj = {'last_notification_error': False, 'last_error': False}
some_data = 'xxxxx'
update_obj["previous_md5"] = hashlib.md5(some_data.encode('utf-8')).hexdigest()

View File

@@ -1,12 +1,11 @@
from babel.numbers import parse_decimal
from changedetectionio.model.Watch import model as BaseWatch
from typing import Union
import re
from babel.numbers import parse_decimal
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.
standardized_value = raw_value
@@ -22,11 +21,8 @@ class Restock(dict):
# Remove any non-numeric characters except for the decimal point
standardized_value = re.sub(r'[^\d.-]', '', standardized_value)
if standardized_value:
# Convert to float
return float(parse_decimal(standardized_value, locale='en'))
return None
# Convert to float
return float(parse_decimal(standardized_value, locale='en'))
def __init__(self, *args, **kwargs):
# Define default values
@@ -49,10 +45,13 @@ class Restock(dict):
def __setitem__(self, key, value):
# Custom logic to handle setting price and original_price
if key == 'price' or key == 'original_price':
if key == 'price':
if isinstance(value, str):
value = self.parse_currency(raw_value=value)
if value and not self.get('original_price'):
self['original_price'] = value
super().__setitem__(key, value)
class Watch(BaseWatch):

View File

@@ -2,7 +2,8 @@ from .. import difference_detection_processor
from ..exceptions import ProcessorException
from . import Restock
from loguru import logger
import hashlib
import re
import urllib3
import time
@@ -26,25 +27,6 @@ def _search_prop_by_value(matches, value):
if value in prop[0]:
return prop[1] # Yield the desired value and exit the function
def _deduplicate_prices(data):
seen = set()
unique_data = []
for datum in data:
# Convert 'value' to float if it can be a numeric string, otherwise leave it as is
try:
normalized_value = float(datum.value) if isinstance(datum.value, str) and datum.value.replace('.', '', 1).isdigit() else datum.value
except ValueError:
normalized_value = datum.value
# If the normalized value hasn't been seen yet, add it to unique data
if normalized_value not in seen:
unique_data.append(datum)
seen.add(normalized_value)
return unique_data
# should return Restock()
# add casting?
def get_itemprop_availability(html_content) -> Restock:
@@ -54,21 +36,17 @@ def get_itemprop_availability(html_content) -> Restock:
"""
from jsonpath_ng import parse
import re
now = time.time()
import extruct
logger.trace(f"Imported extruct module in {time.time() - now:.3f}s")
value = {}
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.
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")
# First phase, dead simple scanning of anything that looks useful
@@ -79,7 +57,7 @@ def get_itemprop_availability(html_content) -> Restock:
pricecurrency_parse = parse('$..(pricecurrency|currency|priceCurrency )')
availability_parse = parse('$..(availability|Availability)')
price_result = _deduplicate_prices(price_parse.find(data))
price_result = price_parse.find(data)
if price_result:
# 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?
@@ -141,8 +119,6 @@ class perform_site_check(difference_detection_processor):
xpath_data = None
def run_changedetection(self, watch, skip_when_checksum_same=True):
import hashlib
if not watch:
raise Exception("Watch no longer exists.")
@@ -156,20 +132,6 @@ class perform_site_check(difference_detection_processor):
update_obj['content_type'] = self.fetcher.headers.get('Content-Type', '')
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?
restock_settings = watch.get('restock_settings', {})
@@ -184,7 +146,7 @@ class perform_site_check(difference_detection_processor):
itemprop_availability = {}
try:
itemprop_availability = get_itemprop_availability(self.fetcher.content)
itemprop_availability = get_itemprop_availability(html_content=self.fetcher.content)
except MoreThanOnePriceFound as e:
# 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.",
@@ -215,11 +177,6 @@ class perform_site_check(difference_detection_processor):
# Main detection method
fetched_md5 = None
# store original price if not set
if itemprop_availability and itemprop_availability.get('price') and not itemprop_availability.get('original_price'):
itemprop_availability['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'):
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.",
@@ -229,25 +186,16 @@ class perform_site_check(difference_detection_processor):
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
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.
# 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
logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned instock_data - '{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
logger.debug(f"Watch UUID {watch.get('uuid')} restock check returned '{self.fetcher.instock_data}' from JS scraper.")
# What we store in the snapshot
price = update_obj.get('restock').get('price') if update_obj.get('restock').get('price') else ""
snapshot_content = f"In Stock: {update_obj.get('restock').get('in_stock')} - Price: {price}"
snapshot_content = f"{update_obj.get('restock').get('in_stock')} - {price}"
# Main detection method
fetched_md5 = hashlib.md5(snapshot_content.encode('utf-8')).hexdigest()

View File

@@ -1,107 +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,
skip_when_checksum_same=False,
)
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 = ''
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 = decompressed_data
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()
trigger_line_numbers = []
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)}"
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,
}
)

View File

@@ -36,7 +36,6 @@ class PDFToHTMLToolNotFound(ValueError):
class perform_site_check(difference_detection_processor):
def run_changedetection(self, watch, skip_when_checksum_same=True):
changed_detected = False
html_content = ""
screenshot = False # as bytes
@@ -176,13 +175,13 @@ class perform_site_check(difference_detection_processor):
html_content=self.fetcher.content,
append_pretty_line_formatting=not watch.is_source_type_url,
is_rss=is_rss)
elif filter_rule.startswith('xpath1:'):
html_content += html_tools.xpath1_filter(xpath_filter=filter_rule.replace('xpath1:', ''),
html_content=self.fetcher.content,
append_pretty_line_formatting=not watch.is_source_type_url,
is_rss=is_rss)
html_content=self.fetcher.content,
append_pretty_line_formatting=not watch.is_source_type_url,
is_rss=is_rss)
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=self.fetcher.content,
append_pretty_line_formatting=not watch.is_source_type_url)
@@ -198,24 +197,20 @@ class perform_site_check(difference_detection_processor):
else:
# extract text
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,
render_anchor_tag_content=do_anchor,
is_rss=is_rss) # 1874 activate the <title workaround hack
stripped_text_from_html = \
html_tools.html_to_text(
html_content=html_content,
render_anchor_tag_content=do_anchor,
is_rss=is_rss # #1874 activate the <title workaround hack
)
if watch.get('trim_text_whitespace'):
stripped_text_from_html = '\n'.join(line.strip() for line in stripped_text_from_html.replace("\n\n", "\n").splitlines())
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'):
if watch.get('sort_text_alphabetically') and stripped_text_from_html:
# Note: Because a <p>something</p> will add an extra line feed to signify the paragraph gap
# 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()))
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
# Also used to calculate/show what was removed
text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8')
# @todo whitespace coming from missing rtrim()?
@@ -240,7 +235,7 @@ class perform_site_check(difference_detection_processor):
if not rendered_diff and stripped_text_from_html:
# We had some content, but no differences were found
# Store our new file as the MD5 so it will trigger in the future
c = hashlib.md5(stripped_text_from_html.encode('utf-8').translate(None, b'\r\n\t ')).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')
else:
stripped_text_from_html = rendered_diff
@@ -295,7 +290,7 @@ class perform_site_check(difference_detection_processor):
for match in res:
regex_matched_output += [match] + [b'\n']
##########################################################
# Now we will only show what the regex matched
stripped_text_from_html = b''
text_content_before_ignored_filter = b''
if regex_matched_output:
@@ -303,8 +298,6 @@ class perform_site_check(difference_detection_processor):
stripped_text_from_html = b''.join(regex_matched_output)
text_content_before_ignored_filter = stripped_text_from_html
# Re #133 - if we should strip whitespaces from triggering the change detected comparison
if self.datastore.data['settings']['application'].get('ignore_whitespace', False):
fetched_md5 = hashlib.md5(stripped_text_from_html.translate(None, b'\r\n\t ')).hexdigest()
@@ -364,5 +357,4 @@ class perform_site_check(difference_detection_processor):
if not watch.get('previous_md5'):
watch['previous_md5'] = fetched_md5
# stripped_text_from_html - Everything after filters and NO 'ignored' content
return changed_detected, update_obj, stripped_text_from_html
return changed_detected, update_obj, text_content_before_ignored_filter

View File

@@ -35,8 +35,4 @@ pytest tests/test_access_control.py
pytest tests/test_notification.py
pytest tests/test_backend.py
pytest tests/test_rss.py
pytest tests/test_unique_lines.py
# Check file:// will pickup a file when enabled
echo "Hello world" > /tmp/test-file.txt
ALLOW_FILE_URI=yes pytest tests/test_security.py
pytest tests/test_unique_lines.py

View File

@@ -16,31 +16,25 @@ echo "---------------------------------- SOCKS5 -------------------"
docker run --network changedet-network \
-v `pwd`/tests/proxy_socks5/proxies.json-example:/app/changedetectionio/test-datastore/proxies.json \
--rm \
-e "FLASK_SERVER_NAME=cdio" \
--hostname cdio \
-e "SOCKSTEST=proxiesjson" \
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
docker run --network changedet-network \
--rm \
-e "FLASK_SERVER_NAME=cdio" \
--hostname cdio \
-e "SOCKSTEST=manual" \
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
docker run --network changedet-network \
-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 \
-e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" \
--rm \
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"
docker logs socks5proxy

View File

@@ -18,11 +18,9 @@ $(document).ready(function () {
});
$(".toggle-show").click(function (e) {
$("#notification-token-toggle").click(function (e) {
e.preventDefault();
let target = $(this).data('target');
$(target).toggle();
$('#notification-tokens-info').toggle();
});
});

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

View File

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

View File

@@ -1,63 +1,53 @@
function redirectToVersion(version) {
var currentUrl = window.location.href.split('?')[0]; // Base URL without query parameters
function redirect_to_version(version) {
var currentUrl = window.location.href;
var baseUrl = currentUrl.split('?')[0]; // Base URL without query parameters
var anchor = '';
// Check if there is an anchor
if (currentUrl.indexOf('#') !== -1) {
anchor = currentUrl.substring(currentUrl.indexOf('#'));
currentUrl = currentUrl.substring(0, currentUrl.indexOf('#'));
if (baseUrl.indexOf('#') !== -1) {
anchor = baseUrl.substring(baseUrl.indexOf('#'));
baseUrl = baseUrl.substring(0, baseUrl.indexOf('#'));
}
window.location.href = currentUrl + '?version=' + version + anchor;
window.location.href = baseUrl + '?version=' + version + anchor;
}
function setupDateWidget() {
$(document).on('keydown', function (event) {
var $selectElement = $('#preview-version');
var $selectedOption = $selectElement.find('option:selected');
if ($selectedOption.length) {
if (event.key === 'ArrowLeft' && $selectedOption.prev().length) {
redirectToVersion($selectedOption.prev().val());
} else if (event.key === 'ArrowRight' && $selectedOption.next().length) {
redirectToVersion($selectedOption.next().val());
document.addEventListener('keydown', function (event) {
var selectElement = document.getElementById('preview-version');
if (selectElement) {
var selectedOption = selectElement.querySelector('option:checked');
if (selectedOption) {
if (event.key === 'ArrowLeft') {
if (selectedOption.previousElementSibling) {
redirect_to_version(selectedOption.previousElementSibling.value);
}
} 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');
document.getElementById('preview-version').addEventListener('change', function () {
redirect_to_version(this.value);
});
if ($selectedOption.length) {
var $prevOption = $selectedOption.prev();
var $nextOption = $selectedOption.next();
if ($prevOption.length) {
$('#btn-previous').attr('href', '?version=' + $prevOption.val());
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 {
$('#btn-previous').remove();
document.getElementById('btn-previous').remove()
}
if (selectedOption.nextElementSibling) {
document.getElementById('btn-next').href = "?version=" + selectedOption.nextElementSibling.value;
} else {
document.getElementById('btn-next').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
}
]);
});

View File

@@ -49,9 +49,4 @@ $(document).ready(function () {
$("#overlay").toggleClass('visible');
heartpath.style.fill = document.getElementById("overlay").classList.contains("visible") ? '#ff0000' : 'var(--color-background)';
});
setInterval(function () {
$('body').toggleClass('spinner-active', $.active > 0);
}, 2000);
});

View File

@@ -12,47 +12,6 @@ function toggleOpacity(checkboxSelector, fieldSelector, inverted) {
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']
}
]);
}).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 () {
$('#notification-setting-reset-to-default').click(function (e) {
$('#notification_title').val('');
@@ -68,21 +27,5 @@ $(document).ready(function () {
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';
$('textarea:visible')[method]('keyup blur', request_textpreview_update.throttle(1000));
$('input:visible')[method]('keyup blur 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"
});
});

View File

@@ -40,29 +40,15 @@
}
}
#browser-steps-fieldlist {
height: 100%;
overflow-y: scroll;
}
#browser-steps .flex-wrapper {
display: flex;
flex-flow: row;
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 :( */

View File

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

View File

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

View File

@@ -12,7 +12,6 @@
@import "parts/_darkmode";
@import "parts/_menu";
@import "parts/_love";
@import "parts/preview_text_filter";
body {
color: var(--color-text);
@@ -106,34 +105,10 @@ button.toggle-button {
padding: 5px;
display: flex;
justify-content: space-between;
border-bottom: 2px solid var(--color-menu-accent);
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 {
color: var(--color-text-menu-heading);
}
@@ -345,6 +320,10 @@ a.pure-button-selected {
background: var(--color-background-button-cancel);
}
#save_button {
margin-right: 1rem;
}
.messages {
li {
list-style: none;
@@ -641,9 +620,9 @@ footer {
list-style: none;
li {
display: flex;
align-items: center;
gap: 1em;
>* {
display: inline-block;
}
}
}
}
@@ -703,12 +682,6 @@ footer {
tr {
th {
display: inline-block;
// Hide the "Last" text for smaller screens
@media (max-width: 768px) {
.hide-on-mobile {
display: none;
}
}
}
}
.empty-cell {
@@ -724,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 {
>span {
vertical-align: middle;
@@ -860,11 +815,6 @@ textarea::placeholder {
- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out
- Rely always on width in CSS
*/
/** Set max width for input field */
.m-d {
min-width: 100%;
}
@media only screen and (min-width: 761px) {
/* m-d is medium-desktop */
@@ -980,13 +930,6 @@ body.full-width {
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 {
padding-left: 0;
color: var(--color-text-input-description);
@@ -1030,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";
#webdriver_delay {

View File

@@ -46,31 +46,14 @@
#browser_steps li > label {
display: none; }
#browser-steps-fieldlist {
height: 100%;
overflow-y: scroll; }
#browser-steps .flex-wrapper {
display: flex;
flex-flow: row;
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; }
height: 70vh; }
/* this is duplicate :( */
#browsersteps-selector-wrapper {
@@ -428,83 +411,6 @@ html[data-darkmode="true"] #toggle-light-mode .icon-dark {
fill: #ff0000 !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 {
color: var(--color-text);
background: var(--color-background-page);
@@ -573,26 +479,9 @@ button.toggle-button {
padding: 5px;
display: flex;
justify-content: space-between;
border-bottom: 2px solid var(--color-menu-accent);
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 {
color: var(--color-text-menu-heading); }
@@ -745,6 +634,9 @@ a.pure-button-selected {
.button-cancel {
background: var(--color-background-button-cancel); }
#save_button {
margin-right: 1rem; }
.messages li {
list-style: none;
padding: 1em;
@@ -943,10 +835,8 @@ footer {
.pure-form .inline-radio ul {
margin: 0px;
list-style: none; }
.pure-form .inline-radio ul li {
display: flex;
align-items: center;
gap: 1em; }
.pure-form .inline-radio ul li > * {
display: inline-block; }
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
.box {
@@ -982,24 +872,12 @@ footer {
.watch-table thead {
display: block; }
.watch-table thead tr th {
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) {
display: inline-block; }
.watch-table thead .empty-cell {
display: none; }
.watch-table tbody td,
.watch-table tbody tr {
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 {
vertical-align: middle; }
.watch-table .last-checked::before {
@@ -1091,10 +969,6 @@ textarea::placeholder {
- We dont use 'size' with <input> because `size` is too unreliable to override, and will often push-out
- Rely always on width in CSS
*/
/** Set max width for input field */
.m-d {
min-width: 100%; }
@media only screen and (min-width: 761px) {
/* m-d is medium-desktop */
.m-d {
@@ -1155,8 +1029,7 @@ body.full-width .edit-form {
.edit-form {
min-width: 70%;
/* so it cant overflow */
max-width: 95%;
/* Make action buttons have consistent size and spacing */ }
max-width: 95%; }
.edit-form .box-wrap {
position: relative; }
.edit-form .inner {
@@ -1165,10 +1038,6 @@ body.full-width .edit-form {
.edit-form #actions {
display: block;
background: var(--color-background); }
.edit-form #actions .pure-control-group {
display: flex;
gap: 0.625em;
flex-wrap: wrap; }
.edit-form .pure-form-message-inline {
padding-left: 0;
color: var(--color-text-input-description); }
@@ -1197,21 +1066,6 @@ ul {
.time-check-widget tr input[type="number"] {
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 {
height: 100%;
text-align: center;
@@ -1340,9 +1194,11 @@ ul {
color: #fff;
opacity: 0.7; }
.restock-label svg {
vertical-align: middle; }
#chrome-extension-link {
padding: 9px;
border: 1px solid var(--color-grey-800);

View File

@@ -11,6 +11,7 @@ from threading import Lock
import json
import os
import re
import requests
import secrets
import threading
import time
@@ -269,7 +270,6 @@ class ChangeDetectionStore:
self.needs_write_urgent = True
def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True):
import requests
if extras is None:
extras = {}

View File

@@ -11,11 +11,8 @@
class="notification-urls" )
}}
<div class="pure-form-message-inline">
<p>
<strong>Tip:</strong> Use <a target=_new href="https://github.com/caronc/apprise">AppRise Notification URLs</a> for notification to just about any service! <i><a target=_new href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.<br>
</p>
<div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div>
<ul style="display: none" id="advanced-help-notifications">
<ul>
<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>
<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> 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 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 class="pure-controls" style="display: none;" id="notification-tokens-info">
<table class="pure-table" id="token-table">

View File

@@ -33,11 +33,9 @@
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
</head>
<body class="">
<body>
<div class="header">
<div class="pure-menu-fixed" style="width: 100%;">
<div class="home-menu pure-menu pure-menu-horizontal" id="nav-menu">
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed" id="nav-menu">
{% if has_password and not current_user.is_authenticated %}
<a class="pure-menu-heading" href="https://changedetection.io" rel="noopener">
<strong>Change</strong>Detection.io</a>
@@ -131,12 +129,7 @@
</li>
</ul>
</div>
<div id="pure-menu-horizontal-spinner"></div>
</div>
</div>
{% if hosted_sticky %}
<div class="sticky-tab" id="hosted-sticky">
<a href="https://changedetection.io/?ref={{guid}}">Let us host your instance!</a>

View File

@@ -4,7 +4,6 @@
{% 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='vis.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script>
<script>
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
@@ -24,7 +23,7 @@
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'] }}";
</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='limit.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
@@ -50,7 +49,7 @@
{% endif %}
{% if watch['processor'] == 'text_json_diff' %}
<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 &amp; Triggers</a></li>
<li class="tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li>
{% endif %}
<li class="tab"><a href="#notifications">Notifications</a></li>
<li class="tab"><a href="#stats">Stats</a></li>
@@ -200,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 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%">
<span class="loader" >
@@ -215,7 +214,7 @@ User-Agent: wonderbra 1.0") }}
<canvas class="noselect" id="browsersteps-selector-canvas" style="max-width: 100%; width: 100%;"></canvas>
</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>
{{ render_field(form.browser_steps) }}
</div>
@@ -254,10 +253,7 @@ User-Agent: wonderbra 1.0") }}
{% if watch['processor'] == 'text_json_diff' %}
<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>
<div id="edit-text-filter">
<div class="pure-control-group" id="pro-tips">
<div class="pure-control-group">
<strong>Pro-tips:</strong><br>
<ul>
<li>
@@ -279,9 +275,9 @@ xpath://body/div/span[contains(@class, 'example-class')]",
{% if '/text()' in field %}
<span class="pure-form-message-inline"><strong>Note!: //text() function does not work where the &lt;element&gt; contains &lt;![CDATA[]]&gt;</strong></span><br>
{% endif %}
<span class="pure-form-message-inline">One CSS, xPath, JSON Path/JQ selector per line, <i>any</i> rules that matches will be used.<br>
<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;">
<span class="pure-form-message-inline">One rule per line, <i>any</i> rules that matches will be used.<br>
<ul>
<li>CSS - Limit text to this CSS rule, only text matching this CSS rule is included.</li>
<li>JSON - Limit text to this JSON rule, using either <a href="https://pypi.org/project/jsonpath-ng/" target="new">JSONPath</a> or <a href="https://stedolan.github.io/jq/" target="new">jq</a> (if installed).
<ul>
@@ -301,25 +297,21 @@ xpath://body/div/span[contains(@class, 'example-class')]",
<li>To use XPath1.0: Prefix with <code>xpath1:</code></li>
</ul>
</li>
<li>
Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a
href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br>
</li>
</ul>
Please be sure that you thoroughly understand how to write CSS, JSONPath, XPath{% if jq_support %}, or jq selector{%endif%} rules before filing an issue on GitHub! <a
href="https://github.com/dgtlmoon/changedetection.io/wiki/CSS-Selector-help">here for more CSS selector help</a>.<br>
</span>
</div>
<fieldset class="pure-control-group">
{{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header
footer
nav
.stockticker
//*[contains(text(), 'Advertisement')]") }}
.stockticker") }}
<span class="pure-form-message-inline">
<ul>
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
<li> Remove HTML element(s) by CSS selector before text conversion. </li>
<li> Don't paste HTML here, use only CSS selectors </li>
<li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
</ul>
</span>
</fieldset>
@@ -334,21 +326,14 @@ nav
<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>
</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 class="pure-control-group">
{{ 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>
</fieldset>
<fieldset class="pure-control-group">
{{ render_checkbox_field(form.trim_text_whitespace) }}
<span class="pure-form-message-inline">Remove any whitespace before and after each line of text</span>
{{ 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>
<div class="pure-control-group">
@@ -371,10 +356,10 @@ nav
") }}
<span class="pure-form-message-inline">
<ul>
<li>Matching text will be <strong>removed</strong> from the text snapshot</li>
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
<li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
<li>Use the preview/show current tab to see ignores</li>
</ul>
</span>
@@ -398,9 +383,7 @@ Unavailable") }}
</fieldset>
<fieldset>
<div class="pure-control-group">
{{ render_field(form.extract_text, rows=5, placeholder="/.+?\d+ comments.+?/
or
keyword") }}
{{ render_field(form.extract_text, rows=5, placeholder="\d+ online") }}
<span class="pure-form-message-inline">
<ul>
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
@@ -420,27 +403,7 @@ keyword") }}
</fieldset>
</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 #}
{% if extra_form_content %}
<div class="tab-pane-inner" id="extras_tab">
@@ -516,12 +479,6 @@ keyword") }}
</tr>
</tbody>
</table>
{% if watch.history_n %}
<p>
<a href="{{url_for('watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">Download latest HTML snapshot</a>
</p>
{% endif %}
</div>
</div>
<div id="actions">

View File

@@ -3,13 +3,11 @@
{% block content %}
<script>
const screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid)}}";
const triggered_line_numbers = {{ triggered_line_numbers|tojson }};
{% if last_error_screenshot %}
const error_screenshot_url = "{{url_for('static_content', group='screenshot', filename=uuid, error_screenshot=1) }}";
{% endif %}
const highlight_submit_ignore_url = "{{url_for('highlight_submit_ignore_url', uuid=uuid)}}";
</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='preview.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="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>
<table>
<tbody>
<tr>
<td id="diff-col" class="highlightable-filter">
<pre style="border-left: 2px solid #ddd;">
{{ content }}
</pre>
{% for row in content %}
<div class="{{ row.classes }}">{{ row.line }}</div>
{% endfor %}
</td>
</tr>
</tbody>

View File

@@ -76,7 +76,7 @@
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }}
<span class="pure-form-message-inline">When a request returns no content, or the HTML does not contain any text, is this considered a change?</span>
<span class="pure-form-message-inline">When a page contains HTML, but no renderable text appears (empty page), is this considered a change?</span>
</div>
{% if form.requests.proxy %}
<div class="pure-control-group inline-radio">
@@ -155,13 +155,11 @@
{{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder="header
footer
nav
.stockticker
//*[contains(text(), 'Advertisement')]") }}
.stockticker") }}
<span class="pure-form-message-inline">
<ul>
<li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li>
<li> Don't paste HTML here, use only CSS and XPath selectors </li>
<li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li>
<li> Remove HTML element(s) by CSS selector before text conversion. </li>
<li> Add multiple elements or CSS selectors per line to ignore multiple parts of the HTML. </li>
</ul>
</span>
</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">
<ul>
<li>Matching text will be <strong>removed</strong> from the text snapshot</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>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>Use the preview/show current tab to see ignores</li>
</ul>
</span>
</fieldset>

View File

@@ -78,8 +78,8 @@
{% if any_has_restock_price_processor %}
<th>Restock &amp; Price</th>
{% endif %}
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('index', sort='last_checked', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Checked <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_changed' else 'inactive' }}" href="{{url_for('index', sort='last_changed', order=link_order, tag=active_tag_uuid)}}"><span class="hide-on-mobile">Last</span> Changed <span class='arrow {{link_order}}'></span></a></th>
<th><a class="{{ 'active '+link_order if sort_attribute == 'last_checked' else 'inactive' }}" href="{{url_for('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)}}">Last Changed <span class='arrow {{link_order}}'></span></a></th>
<th class="empty-cell"></th>
</tr>
</thead>
@@ -191,9 +191,9 @@
{% if watch.history_n >= 2 %}
{% 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 %}
<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 %}
{% else %}

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import resource
import time
from threading import Thread

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
# !/usr/bin/python3
import os
from flask import url_for

View File

@@ -1,3 +1,3 @@
#!/usr/bin/env python3
#!/usr/bin/python3
from .. import conftest

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
from .. import conftest

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import os
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for
@@ -44,7 +44,7 @@ def test_select_custom(client, live_server, measure_memory_usage):
follow_redirects=True
)
# 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

View File

@@ -1,27 +1,12 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import os
import time
from flask import url_for
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):
live_server_setup(live_server)
set_response()
# Setup a proxy
res = client.post(
@@ -39,10 +24,7 @@ def test_socks5(client, live_server, measure_memory_usage):
assert b"Settings updated." in res.data
# Because the socks server should connect back to us
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')
test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '')
res = client.post(
url_for("form_quick_watch_add"),
@@ -78,4 +60,4 @@ def test_socks5(client, live_server, measure_memory_usage):
)
# Should see the proper string
assert "Awesome, you made it".encode('utf-8') in res.data
assert "+0200:".encode('utf-8') in res.data

View File

@@ -1,32 +1,16 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import os
import time
from flask import url_for
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
# -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):
live_server_setup(live_server)
set_response()
# Because the socks server should connect back to us
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')
test_url = "https://changedetection.io/CHANGELOG.txt?socks-test-tag=" + os.getenv('SOCKSTEST', '')
res = client.get(url_for("settings_page"))
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
assert "Awesome, you made it".encode('utf-8') in res.data
assert "+0200:".encode('utf-8') in res.data

View File

@@ -1,3 +1,3 @@
#!/usr/bin/env python3
#!/usr/bin/python3
from .. import conftest

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import os
import time
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 (
default_notification_body,
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
# We should have a notification
wait_for_notification_endpoint_output()
time.sleep(2)
assert os.path.isfile("test-datastore/notification.txt"), "Notification received"
os.unlink("test-datastore/notification.txt")
@@ -103,7 +103,6 @@ def test_restock_detection(client, live_server, measure_memory_usage):
set_original_response()
client.get(url_for("form_watch_checknow"), follow_redirects=True)
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"
# BUT we should see that it correctly shows "not in stock"

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import asyncio
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import os.path
import time
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
@@ -39,8 +39,9 @@ def test_setup(client, live_server, measure_memory_usage):
live_server_setup(live_server)
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
time.sleep(1)
set_original()
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
@@ -111,7 +112,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
res = client.post(
url_for("settings_page"),
data={"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
"application-notification_body": 'triggered text was -{{triggered_text}}- 网站监测 内容更新了',
"application-notification_body": 'triggered text was -{{triggered_text}}-',
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_urls": test_notification_url,
"application-minutes_between_check": 180,
@@ -151,9 +152,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
# A line thats not the trigger should not trigger anything
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)
res = client.get(url_for("index"))
assert b'unviewed' not in res.data
@@ -166,12 +165,11 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
assert b'unviewed' in res.data
# 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"
with open("test-datastore/notification.txt", 'rb') as f:
response = f.read()
assert b'-Oh yes please-' in response
assert '网站监测 内容更新了'.encode('utf-8') in response
with open("test-datastore/notification.txt", 'r') as f:
response= f.read()
assert '-Oh yes please-' in response
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for
@@ -69,12 +69,6 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
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
res = client.get(url_for("index"))
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
# 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"
# Check the [preview] pulls the right one
@@ -149,6 +143,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
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
uuid = extract_UUID_from_client(client)
client.get(url_for("clear_watch_history", uuid=uuid))
client.get(url_for("form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
from .util import set_original_response, live_server_setup, wait_for_all_checks
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
@@ -87,9 +87,6 @@ def test_element_removal_output():
Some initial text<br>
<p>across multiple lines</p>
<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>
<footer>
<p>Footer</p>
@@ -97,16 +94,7 @@ def test_element_removal_output():
</html>
"""
html_blob = element_removal(
[
"header",
"footer",
"nav",
"#changetext",
"//*[contains(text(), 'xPath // selector')]",
"xpath://*[contains(text(), 'xPath selector')]",
"xpath1://*[contains(text(), 'xPath1 selector')]"
],
html_content=content
["header", "footer", "nav", "#changetext"], html_content=content
)
text = get_text(html_blob)
assert (

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python3
#!/usr/bin/python3
# coding=utf-8
import time
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
@@ -38,11 +38,6 @@ def test_check_encoding_detection(client, live_server, measure_memory_usage):
# Give the thread time to pick it up
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(
url_for("preview_page", uuid="first"),
follow_redirects=True

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for
@@ -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
assert b'and this should be' in res.data
assert b'Something' in res.data
assert b'across 6 billion multiple' in res.data
assert b'lines' in res.data
assert b'<div class="">Something' in res.data
assert b'<div class="">across 6 billion multiple' 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)
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
)
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
assert b'2000 online' in res.data
assert b'<div class="">2000 online' in res.data
# 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
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
assert b'3456' in res.data
assert b'<div class="">3456' in res.data
# Regex with multiline flag handling should be here

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env python3
#!/usr/bin/python3
# https://www.reddit.com/r/selfhosted/comments/wa89kp/comment/ii3a4g7/?context=3
import os
import time
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
@@ -102,15 +102,14 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_notification_endpoint_output()
time.sleep(3)
# Shouldn't exist, shouldn't have fired
assert not os.path.isfile("test-datastore/notification.txt")
# Now the filter should exist
set_response_with_filter()
client.get(url_for("form_watch_checknow"), follow_redirects=True)
wait_for_notification_endpoint_output()
time.sleep(3)
assert os.path.isfile("test-datastore/notification.txt")

View File

@@ -1,9 +1,7 @@
import os
import time
from loguru import logger
from flask import url_for
from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks, \
wait_for_notification_endpoint_output
from .util import set_original_response, live_server_setup, extract_UUID_from_client, wait_for_all_checks
from changedetectionio.model import App
@@ -28,12 +26,6 @@ def run_filter_test(client, live_server, content_filter):
# Response WITHOUT the filter ID element
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
client.get(
url_for("form_delete", uuid="all"),
@@ -42,92 +34,83 @@ def run_filter_test(client, live_server, content_filter):
if os.path.isfile("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(
url_for("import_page"),
data={"urls": test_url},
url_for("form_quick_watch_add"),
data={"url": test_url, "tags": ''},
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)
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,
"notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
"notification_body": "BASE URL: {{base_url}}\n"
"Watch URL: {{watch_url}}\n"
"Watch UUID: {{watch_uuid}}\n"
"Watch title: {{watch_title}}\n"
"Watch tag: {{watch_tag}}\n"
"Preview: {{preview_url}}\n"
"Diff URL: {{diff_url}}\n"
"Snapshot: {{current_snapshot}}\n"
"Diff: {{diff}}\n"
"Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n"
":-)",
"notification_format": "Text",
"fetch_backend": "html_requests",
"filter_failure_notification_send": 'y',
"headers": "",
"tags": "my tag",
"title": "my title 123",
"time_between_check-hours": 5, # So that the queue runner doesnt also put it in
"url": test_url,
}
# 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_body": "BASE URL: {{base_url}}\n"
"Watch URL: {{watch_url}}\n"
"Watch UUID: {{watch_uuid}}\n"
"Watch title: {{watch_title}}\n"
"Watch tag: {{watch_tag}}\n"
"Preview: {{preview_url}}\n"
"Diff URL: {{diff_url}}\n"
"Snapshot: {{current_snapshot}}\n"
"Diff: {{diff}}\n"
"Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n"
":-)",
"notification_format": "Text"}
notification_form_data.update({
"url": test_url,
"tags": "my tag",
"title": "my title 123",
"headers": "",
"filter_failure_notification_send": 'y',
"include_filters": content_filter,
"fetch_backend": "html_requests"})
# A POST here will also reset the filter failure counter (filter_failure_notification_threshold_attempts)
res = client.post(
url_for("edit_page", uuid=uuid),
data=watch_data,
url_for("edit_page", uuid="first"),
data=notification_form_data,
follow_redirects=True
)
assert b"Updated watch." in res.data
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
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)
# Now the notification should not exist, because we didnt reach the threshold
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)
# Add 4 more checks
checked = 0
ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)
for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2):
checked += 1
for i in range(0, App._FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT-2):
client.get(url_for("form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("index"))
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) # 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"))
assert b'Warning, no filters were found' in res.data
time.sleep(2)
# One more check should trigger the _FILTER_FAILURE_THRESHOLD_ATTEMPTS_DEFAULT threshold
client.get(url_for("form_watch_checknow"), follow_redirects=True)
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
assert os.path.isfile("test-datastore/notification.txt")
with open("test-datastore/notification.txt", 'r') as f:
notification = f.read()
@@ -140,11 +123,10 @@ def run_filter_test(client, live_server, content_filter):
set_response_with_filter()
# 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)
wait_for_all_checks(client)
wait_for_notification_endpoint_output()
# It should have sent a notification, but..
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)
@@ -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
# 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
# cleanup for the next
@@ -167,11 +152,9 @@ def test_setup(live_server):
live_server_setup(live_server)
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')
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"]')
# Test that notification is never sent

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
import os

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
"""Test suite for the method to extract text from an html string"""
from ..html_tools import html_to_text

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
from . util import live_server_setup
from changedetectionio import html_tools

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for
@@ -79,14 +79,14 @@ def set_modified_ignore_response():
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):
# Use a mix of case in ZzZ to prove it works case-insensitive.
ignore_text = "XXXXX\r\nYYYYY\r\nzZzZZ\r\nnew ignore stuff"
set_original_ignore_response()
# Give the endpoint time to spin up
time.sleep(1)
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
@@ -151,10 +151,12 @@ def test_check_ignore_text_functionality(client, live_server, measure_memory_usa
res = client.get(url_for("index"))
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"))
# Should no longer be in the preview
assert b'new ignore stuff' not in res.data
# We should be able to see what we ignored
assert b'<div class="ignored">new ignore stuff' in res.data
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for
@@ -23,7 +23,7 @@ def set_original_ignore_response():
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)
set_original_ignore_response()
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
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))
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
assert b'csrftoken' in res.data

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
"""Test suite for the render/not render anchor tag content functionality"""
import time

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import io
import os
import time

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
# coding=utf-8
import time
@@ -499,7 +499,7 @@ def test_correct_header_detect(client, live_server, measure_memory_usage):
)
assert b'&#34;hello&#34;: 123,' in res.data
assert b'&#34;world&#34;: 123' in res.data
assert b'&#34;world&#34;: 123</div>' in res.data
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data

View File

@@ -1,8 +1,11 @@
#!/usr/bin/env python3
#!/usr/bin/python3
from flask import url_for
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
import time
from flask import url_for
from urllib.request import urlopen
from .util import set_original_response, set_modified_response, live_server_setup
sleep_time_for_fetch_thread = 3
def set_nonrenderable_response():
@@ -13,18 +16,12 @@ def set_nonrenderable_response():
</body>
</html>
"""
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write(test_return_data)
time.sleep(1)
return None
def set_zero_byte_response():
with open("test-datastore/endpoint-content.txt", "w") as f:
f.write("")
time.sleep(1)
return None
def test_check_basic_change_detection_functionality(client, live_server, measure_memory_usage):
set_original_response()
live_server_setup(live_server)
@@ -38,11 +35,18 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
assert b"1 Imported" in res.data
wait_for_all_checks(client)
time.sleep(sleep_time_for_fetch_thread)
# It should report nothing found (no new 'unviewed' class)
res = client.get(url_for("index"))
assert b'unviewed' not in res.data
# Do this a few times.. ensures we dont accidently set the status
for n in range(3):
client.get(url_for("form_watch_checknow"), follow_redirects=True)
# Give the thread time to pick it up
time.sleep(sleep_time_for_fetch_thread)
# It should report nothing found (no new 'unviewed' class)
res = client.get(url_for("index"))
assert b'unviewed' not in res.data
#####################
@@ -60,7 +64,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
client.get(url_for("form_watch_checknow"), follow_redirects=True)
# Give the thread time to pick it up
wait_for_all_checks(client)
time.sleep(sleep_time_for_fetch_thread)
# It should report nothing found (no new 'unviewed' class)
res = client.get(url_for("index"))
@@ -82,20 +86,14 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
client.get(url_for("form_watch_checknow"), follow_redirects=True)
# Give the thread time to pick it up
wait_for_all_checks(client)
time.sleep(sleep_time_for_fetch_thread)
# It should report nothing found (no new 'unviewed' class)
res = client.get(url_for("index"))
assert b'unviewed' in res.data
client.get(url_for("mark_all_viewed"), follow_redirects=True)
# A totally zero byte (#2528) response should also not trigger an error
set_zero_byte_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' in res.data # A change should have registered because empty_pages_are_a_change is ON
assert b'fetch-error' not in res.data
#
# Cleanup everything

View File

@@ -3,8 +3,6 @@ import os
import time
import re
from flask import url_for
from loguru import logger
from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks, \
set_longer_modified_response
from . util import extract_UUID_from_client
@@ -291,11 +289,11 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了" }',
"application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444 }',
"application-notification_format": default_notification_format,
"application-notification_urls": test_notification_url,
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }} ",
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
},
follow_redirects=True
)
@@ -324,7 +322,6 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
j = json.loads(x)
assert j['url'].startswith('http://localhost')
assert j['secret'] == 444
assert j['somebug'] == '网站监测 内容更新了'
# URL check, this will always be converted to lowercase
assert os.path.isfile("test-datastore/notification-url.txt")
@@ -350,82 +347,3 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
url_for("form_delete", uuid="all"),
follow_redirects=True
)
#2510
def test_global_send_test_notification(client, live_server, measure_memory_usage):
#live_server_setup(live_server)
set_original_response()
if os.path.isfile("test-datastore/notification.txt"):
os.unlink("test-datastore/notification.txt")
# otherwise other settings would have already existed from previous tests in this file
res = client.post(
url_for("settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
#1995 UTF-8 content should be encoded
"application-notification_body": 'change detection is cool 网站监测 内容更新了',
"application-notification_format": default_notification_format,
"application-notification_urls": "",
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
},
follow_redirects=True
)
assert b'Settings updated' in res.data
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("form_quick_watch_add"),
data={"url": test_url, "tags": 'nice one'},
follow_redirects=True
)
assert b"Watch added" in res.data
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123"
######### Test global/system settings
res = client.post(
url_for("ajax_callback_send_notification_test")+"?mode=global-settings",
data={"notification_urls": test_notification_url},
follow_redirects=True
)
assert res.status_code != 400
assert res.status_code != 500
# Give apprise time to fire
time.sleep(4)
with open("test-datastore/notification.txt", 'r') as f:
x = f.read()
assert 'change detection is cool 网站监测 内容更新了' in x
os.unlink("test-datastore/notification.txt")
######### Test group/tag settings
res = client.post(
url_for("ajax_callback_send_notification_test")+"?mode=group-settings",
data={"notification_urls": test_notification_url},
follow_redirects=True
)
assert res.status_code != 400
assert res.status_code != 500
# Give apprise time to fire
time.sleep(4)
with open("test-datastore/notification.txt", 'r') as f:
x = f.read()
# Should come from notification.py default handler when there is no notification body to pull from
assert 'change detection is cool 网站监测 内容更新了' in x
client.get(
url_for("form_delete", uuid="all"),
follow_redirects=True
)

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import os
import time
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 ..notification import default_notification_format
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'},
follow_redirects=True
)
# A change in price, should trigger a change by default
wait_for_all_checks(client)
data = {
"tags": "",
"url": test_url,
"headers": "",
"time_between_check-hours": 5,
'fetch_backend': "html_requests"
}
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'unviewed' not in res.data
# price changed to something LESS than min (900), SHOULD be a change
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)
assert b'1 watches queued for rechecking.' in res.data
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)
wait_for_all_checks(client)
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' in res.data or b'1890.45' in res.data
assert b'1,890.45' or b'1890.45' in res.data
assert b'unviewed' in res.data
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')
client.get(url_for("form_watch_checknow"))
wait_for_all_checks(client)
wait_for_notification_endpoint_output()
time.sleep(3)
assert os.path.isfile("test-datastore/notification.txt"), "Notification received"
with open("test-datastore/notification.txt", 'r') as f:
notification = f.read()

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,12 +1,7 @@
import os
from flask import url_for
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
import time
from .. import strtobool
def test_setup(client, live_server, measure_memory_usage):
live_server_setup(live_server)
@@ -60,33 +55,17 @@ def test_bad_access(client, live_server, measure_memory_usage):
assert b'Watch protocol is not permitted by SAFE_PROTOCOL_REGEX' in res.data
def test_file_access(client, live_server, measure_memory_usage):
#live_server_setup(live_server)
test_file_path = "/tmp/test-file.txt"
# file:// is permitted by default, but it will be caught by ALLOW_FILE_URI
client.post(
url_for("form_quick_watch_add"),
data={"url": f"file://{test_file_path}", "tags": ''},
data={"url": 'file:///tasty/disk/drive', "tags": ''},
follow_redirects=True
)
wait_for_all_checks(client)
res = client.get(url_for("index"))
# If it is enabled at test time
if strtobool(os.getenv('ALLOW_FILE_URI', 'false')):
res = client.get(
url_for("preview_page", uuid="first"),
follow_redirects=True
)
# Should see something (this file added by run_basic_tests.sh)
assert b"Hello world" in res.data
else:
# Default should be here
assert b'file:// type access is denied for security reasons.' in res.data
assert b'file:// type access is denied for security reasons.' in res.data
def test_xss(client, live_server, measure_memory_usage):
#live_server_setup(live_server)

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
from flask import url_for

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3
#!/usr/bin/python3
import time
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():
@@ -59,9 +59,12 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
live_server_setup(live_server)
sleep_time_for_fetch_thread = 3
trigger_text = "Add to cart"
set_original_ignore_response()
# Give the endpoint time to spin up
time.sleep(1)
# Add our URL to the import page
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
wait_for_all_checks(client)
# Check it saved
res = client.get(
url_for("edit_page", uuid="first"),
)
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
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
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)
res = client.get(url_for("index"))
@@ -113,17 +117,19 @@ def test_trigger_functionality(client, live_server, measure_memory_usage):
# Trigger a check
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)
res = client.get(url_for("index"))
assert b'unviewed' not in res.data
# Now set the content which contains the trigger text
time.sleep(sleep_time_for_fetch_thread)
set_modified_with_trigger_text_response()
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"))
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"))
# 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'const triggered_line_numbers = [6]' in res.data
assert b'Add to cart' in res.data
assert b'<div class="triggered">Add to cart' in res.data

Some files were not shown because too many files have changed in this diff Show More