From 499c4797daa6d9a5bdf8da68f0ec72bd051f0389 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sat, 12 Feb 2022 18:08:18 +0100 Subject: [PATCH] More works and tests --- changedetectionio/content_fetcher.py | 23 +++++-- changedetectionio/fetch_site_status.py | 24 ++++---- changedetectionio/tests/test_binary_fetch.py | 56 ++++++++++++++++++ .../tests/tux-penguin-changed.jpg | Bin 0 -> 12597 bytes changedetectionio/tests/tux-penguin.jpg | Bin 0 -> 4355 bytes changedetectionio/tests/util.py | 10 ++++ 6 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 changedetectionio/tests/test_binary_fetch.py create mode 100644 changedetectionio/tests/tux-penguin-changed.jpg create mode 100644 changedetectionio/tests/tux-penguin.jpg diff --git a/changedetectionio/content_fetcher.py b/changedetectionio/content_fetcher.py index 890ff65d..342f28c4 100644 --- a/changedetectionio/content_fetcher.py +++ b/changedetectionio/content_fetcher.py @@ -5,8 +5,9 @@ from selenium import webdriver from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.common.proxy import Proxy as SeleniumProxy from selenium.common.exceptions import WebDriverException -import urllib3.exceptions +# image/jpeg etc +supported_binary_types = ['image'] class EmptyReply(Exception): def __init__(self, status_code, url): @@ -51,6 +52,15 @@ class Fetcher(): # def return_diff(self, stream_a, stream_b): # return +# Assume we dont support it as binary if its not in our list +def supported_binary_type(content_type): + # Not a binary thing we support? then use text (also used for JSON/XML etc) + # @todo - future - use regex for matching + if content_type and content_type.lower().strip().split('/')[0] not in (string.lower() for string in supported_binary_types): + return False + + return True + def available_fetchers(): import inspect from changedetectionio import content_fetcher @@ -156,15 +166,18 @@ class html_requests(Fetcher): verify=False) # https://stackoverflow.com/questions/44203397/python-requests-get-returns-improperly-decoded-text-instead-of-utf-8 - # Return bytes here - html = r.text + + if not supported_binary_type(r.headers.get('Content-Type', '')): + content = r.text + else: + content = r.content # @todo test this # @todo maybe you really want to test zero-byte return pages? - if not r or not html or not len(html): + if not r or not content or not len(content): raise EmptyReply(url=url, status_code=r.status_code) self.status_code = r.status_code - self.content = html + self.content = content self.headers = r.headers diff --git a/changedetectionio/fetch_site_status.py b/changedetectionio/fetch_site_status.py index df4af2d5..bf24df12 100644 --- a/changedetectionio/fetch_site_status.py +++ b/changedetectionio/fetch_site_status.py @@ -57,7 +57,7 @@ class perform_site_check(): stripped_text_from_html = "" fetched_md5 = "" - text_content_before_ignored_filter = False + original_content_before_filters = False watch = self.datastore.data['watching'][uuid] @@ -106,13 +106,16 @@ class perform_site_check(): # https://stackoverflow.com/questions/41817578/basic-method-chaining ? # return content().textfilter().jsonextract().checksumcompare() ? update_obj['content-type'] = fetcher.headers.get('Content-Type', '').lower().strip() + is_json = update_obj['content-type'] == 'application/json' is_text_or_html = 'text' in update_obj['content-type'] - is_binary = 'image' in update_obj['content-type'] + is_binary = content_fetcher.supported_binary_type(update_obj['content-type']) css_filter_rule = watch['css_filter'] has_filter_rule = css_filter_rule and len(css_filter_rule.strip()) + + # Make it reformat the JSON to something nice if is_json and not has_filter_rule: css_filter_rule = "json:$" has_filter_rule = True @@ -120,7 +123,7 @@ class perform_site_check(): if has_filter_rule: if 'json:' in css_filter_rule: stripped_text_from_html = html_tools.extract_json_as_string(content=fetcher.content, jsonpath_filter=css_filter_rule) - is_html = False + is_text_or_html = False if is_text_or_html: # CSS Filter, extract the HTML that matches and feed that into the existing inscriptis::get_text @@ -142,7 +145,7 @@ class perform_site_check(): stripped_text_from_html = html_content # Re #340 - return the content before the 'ignore text' was applied - text_content_before_ignored_filter = stripped_text_from_html.encode('utf-8') + original_content_before_filters = stripped_text_from_html.encode('utf-8') # We rely on the actual text in the html output.. many sites have random script vars etc, # in the future we'll implement other mechanisms. @@ -159,8 +162,6 @@ class perform_site_check(): else: stripped_text_from_html = stripped_text_from_html.encode('utf8') - - if is_text_or_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() @@ -175,8 +176,11 @@ class perform_site_check(): # Goal here in the future is to be able to abstract out different content type checks into their own class if is_binary: - fetched_md5 = hashlib.md5(fetcher.content) - text_content_before_ignored_filter = fetcher.content + # @todo - use some actual image hash here where possible, audio hash, etc etc + m = hashlib.sha256() + m.update(fetcher.content) + fetched_md5 = m.hexdigest() + original_content_before_filters = fetcher.content # On the first run of a site, watch['previous_md5'] will be an empty string, set it the current one. if not len(watch['previous_md5']): @@ -208,5 +212,5 @@ class perform_site_check(): update_obj["last_changed"] = timestamp - # text_content_before_ignored_filter is returned for saving the data to disk - return changed_detected, update_obj, text_content_before_ignored_filter + # original_content_before_filters is returned for saving the data to disk + return changed_detected, update_obj, original_content_before_filters diff --git a/changedetectionio/tests/test_binary_fetch.py b/changedetectionio/tests/test_binary_fetch.py new file mode 100644 index 00000000..965117b7 --- /dev/null +++ b/changedetectionio/tests/test_binary_fetch.py @@ -0,0 +1,56 @@ +#!/usr/bin/python3 + +import time +import secrets +from flask import url_for +from . util import live_server_setup + + +def test_binary_file_change(client, live_server): + with open("test-datastore/test.bin", "wb") as f: + f.write(secrets.token_bytes()) + + live_server_setup(live_server) + + sleep_time_for_fetch_thread = 3 + + # Give the endpoint time to spin up + time.sleep(1) + + # Add our URL to the import page + test_url = url_for('test_binaryfile_endpoint', _external=True) + res = client.post( + url_for("import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + assert b"1 Imported" in res.data + + # Trigger a check + client.get(url_for("api_watch_checknow"), follow_redirects=True) + + # Give the thread time to pick it up + time.sleep(sleep_time_for_fetch_thread) + + # Trigger a check + client.get(url_for("api_watch_checknow"), follow_redirects=True) + + # It should report nothing found (no new 'unviewed' class) + res = client.get(url_for("index")) + assert b'unviewed' not in res.data + assert b'/test-binary-endpoint' in res.data + + # Make a change + with open("test-datastore/test.bin", "wb") as f: + f.write(secrets.token_bytes()) + + + # Trigger a check + client.get(url_for("api_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' in res.data diff --git a/changedetectionio/tests/tux-penguin-changed.jpg b/changedetectionio/tests/tux-penguin-changed.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2cc8ef5fa5cfd10e8b4b0402d328c173102eb8be GIT binary patch literal 12597 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