mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-11-04 00:27:48 +00:00 
			
		
		
		
	Compare commits
	
		
			59 Commits
		
	
	
		
			0.50.29
			...
			550-visual
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					3afccbe9c9 | ||
| 
						 | 
					6af778aea4 | ||
| 
						 | 
					63459d1504 | ||
| 
						 | 
					73e27c6b24 | ||
| 
						 | 
					de1739dd09 | ||
| 
						 | 
					766a7ea746 | ||
| 
						 | 
					c379035480 | ||
| 
						 | 
					a737a653fa | ||
| 
						 | 
					4cbdb92074 | ||
| 
						 | 
					c29058dcaf | ||
| 
						 | 
					875319f910 | ||
| 
						 | 
					7be8f9296d | ||
| 
						 | 
					6af88062a8 | ||
| 
						 | 
					e5568cf744 | ||
| 
						 | 
					d82cec5446 | ||
| 
						 | 
					5eecf138c0 | ||
| 
						 | 
					13aabd48db | ||
| 
						 | 
					a05f8f2bf2 | ||
| 
						 | 
					1594853ce5 | ||
| 
						 | 
					49139e779a | ||
| 
						 | 
					5e7324b0b8 | ||
| 
						 | 
					8b038374f9 | ||
| 
						 | 
					695fcc4566 | ||
| 
						 | 
					d7c5a53315 | ||
| 
						 | 
					573e92c5e5 | ||
| 
						 | 
					57ba77e287 | ||
| 
						 | 
					22e9d96739 | ||
| 
						 | 
					6f35c2eec9 | ||
| 
						 | 
					9c8894a875 | ||
| 
						 | 
					371c1c974a | ||
| 
						 | 
					22c1a63167 | ||
| 
						 | 
					8ce75f40d9 | ||
| 
						 | 
					5f3251a3e1 | ||
| 
						 | 
					703922c369 | ||
| 
						 | 
					22dda97a65 | ||
| 
						 | 
					8134242b38 | ||
| 
						 | 
					dc8f20d104 | ||
| 
						 | 
					704452322a | ||
| 
						 | 
					588820d2fe | ||
| 
						 | 
					12aa77ee35 | ||
| 
						 | 
					a086991b54 | ||
| 
						 | 
					ac236ee88c | ||
| 
						 | 
					26c56c3fc4 | ||
| 
						 | 
					a7e6cc5c62 | ||
| 
						 | 
					245fea07ac | ||
| 
						 | 
					013ae339e0 | ||
| 
						 | 
					8eccbaa050 | ||
| 
						 | 
					71d007a6aa | ||
| 
						 | 
					a038cfe046 | ||
| 
						 | 
					d819d37463 | ||
| 
						 | 
					ea4a8ed580 | ||
| 
						 | 
					eef98c6adc | ||
| 
						 | 
					4b7774db29 | ||
| 
						 | 
					1be1cee04d | ||
| 
						 | 
					c990db2bd5 | ||
| 
						 | 
					25a7fd050f | ||
| 
						 | 
					f71545a4b0 | ||
| 
						 | 
					d87a8cc661 | ||
| 
						 | 
					0d114f2adc | 
@@ -626,6 +626,12 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            if request.method == 'POST' and not form.validate():
 | 
			
		||||
                flash("An error occurred, please see below.", "error")
 | 
			
		||||
 | 
			
		||||
            visualselector_data_is_ready = datastore.visualselector_data_is_ready(uuid)
 | 
			
		||||
 | 
			
		||||
            # Only works reliably with Playwright
 | 
			
		||||
            visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and default['fetch_backend'] == 'html_webdriver'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            output = render_template("edit.html",
 | 
			
		||||
                                     uuid=uuid,
 | 
			
		||||
                                     watch=datastore.data['watching'][uuid],
 | 
			
		||||
@@ -633,7 +639,9 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
                                     has_empty_checktime=using_default_check_time,
 | 
			
		||||
                                     using_global_webdriver_wait=default['webdriver_delay'] is None,
 | 
			
		||||
                                     current_base_url=datastore.data['settings']['application']['base_url'],
 | 
			
		||||
                                     emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False)
 | 
			
		||||
                                     emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
 | 
			
		||||
                                     visualselector_data_is_ready=visualselector_data_is_ready,
 | 
			
		||||
                                     visualselector_enabled=visualselector_enabled
 | 
			
		||||
                                     )
 | 
			
		||||
 | 
			
		||||
        return output
 | 
			
		||||
@@ -976,10 +984,9 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
 | 
			
		||||
    @app.route("/static/<string:group>/<string:filename>", methods=['GET'])
 | 
			
		||||
    def static_content(group, filename):
 | 
			
		||||
        from flask import make_response
 | 
			
		||||
 | 
			
		||||
        if group == 'screenshot':
 | 
			
		||||
 | 
			
		||||
            from flask import make_response
 | 
			
		||||
 | 
			
		||||
            # Could be sensitive, follow password requirements
 | 
			
		||||
            if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
 | 
			
		||||
                abort(403)
 | 
			
		||||
@@ -998,6 +1005,26 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
            except FileNotFoundError:
 | 
			
		||||
                abort(404)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        if group == 'visual_selector_data':
 | 
			
		||||
            # Could be sensitive, follow password requirements
 | 
			
		||||
            if datastore.data['settings']['application']['password'] and not flask_login.current_user.is_authenticated:
 | 
			
		||||
                abort(403)
 | 
			
		||||
 | 
			
		||||
            # These files should be in our subdirectory
 | 
			
		||||
            try:
 | 
			
		||||
                # set nocache, set content-type
 | 
			
		||||
                watch_dir = datastore_o.datastore_path + "/" + filename
 | 
			
		||||
                response = make_response(send_from_directory(filename="elements.json", directory=watch_dir, path=watch_dir + "/elements.json"))
 | 
			
		||||
                response.headers['Content-type'] = 'application/json'
 | 
			
		||||
                response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
 | 
			
		||||
                response.headers['Pragma'] = 'no-cache'
 | 
			
		||||
                response.headers['Expires'] = 0
 | 
			
		||||
                return response
 | 
			
		||||
 | 
			
		||||
            except FileNotFoundError:
 | 
			
		||||
                abort(404)
 | 
			
		||||
 | 
			
		||||
        # These files should be in our subdirectory
 | 
			
		||||
        try:
 | 
			
		||||
            return send_from_directory("static/{}".format(group), path=filename)
 | 
			
		||||
@@ -1150,7 +1177,6 @@ def changedetection_app(config=None, datastore_o=None):
 | 
			
		||||
        # paste in etc
 | 
			
		||||
        return redirect(url_for('index'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # @todo handle ctrl break
 | 
			
		||||
    ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -27,6 +27,117 @@ class Fetcher():
 | 
			
		||||
    status_code = None
 | 
			
		||||
    content = None
 | 
			
		||||
    headers = None
 | 
			
		||||
 | 
			
		||||
    fetcher_description = "No description"
 | 
			
		||||
    xpath_element_js = """               
 | 
			
		||||
                // Include the getXpath script directly, easier than fetching
 | 
			
		||||
                !function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).getXPath=n()}(this,function(){return function(e){var n=e;if(n&&n.id)return'//*[@id="'+n.id+'"]';for(var o=[];n&&Node.ELEMENT_NODE===n.nodeType;){for(var i=0,r=!1,d=n.previousSibling;d;)d.nodeType!==Node.DOCUMENT_TYPE_NODE&&d.nodeName===n.nodeName&&i++,d=d.previousSibling;for(d=n.nextSibling;d;){if(d.nodeName===n.nodeName){r=!0;break}d=d.nextSibling}o.push((n.prefix?n.prefix+":":"")+n.localName+(i||r?"["+(i+1)+"]":"")),n=n.parentNode}return o.length?"/"+o.reverse().join("/"):""}});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                const findUpTag = (el) => {
 | 
			
		||||
                  let r = el
 | 
			
		||||
                  chained_css = [];
 | 
			
		||||
                  depth=0;
 | 
			
		||||
            
 | 
			
		||||
                // Strategy 1: Keep going up until we hit an ID tag, imagine it's like  #list-widget div h4
 | 
			
		||||
                  while (r.parentNode) {
 | 
			
		||||
                    if(depth==5) {
 | 
			
		||||
                      break;
 | 
			
		||||
                    }
 | 
			
		||||
                    if('' !==r.id) {
 | 
			
		||||
                      chained_css.unshift("#"+r.id);
 | 
			
		||||
                      final_selector= chained_css.join('>');
 | 
			
		||||
                      // Be sure theres only one, some sites have multiples of the same ID tag :-(
 | 
			
		||||
                      if (window.document.querySelectorAll(final_selector).length ==1 ) {
 | 
			
		||||
                        return final_selector;
 | 
			
		||||
                      }
 | 
			
		||||
                      return null;
 | 
			
		||||
                    } else {
 | 
			
		||||
                      chained_css.unshift(r.tagName.toLowerCase());
 | 
			
		||||
                    }
 | 
			
		||||
                    r=r.parentNode;
 | 
			
		||||
                    depth+=1;
 | 
			
		||||
                  }
 | 
			
		||||
                  return null;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                // @todo - if it's SVG or IMG, go into image diff mode
 | 
			
		||||
                var elements = window.document.querySelectorAll("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");
 | 
			
		||||
                var size_pos=[];
 | 
			
		||||
                // after page fetch, inject this JS
 | 
			
		||||
                // build a map of all elements and their positions (maybe that only include text?)
 | 
			
		||||
                var bbox;
 | 
			
		||||
                for (var i = 0; i < elements.length; i++) {   
 | 
			
		||||
                 bbox = elements[i].getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
                 // forget really small ones
 | 
			
		||||
                 if (bbox['width'] <20 && bbox['height'] < 20 ) {
 | 
			
		||||
                   continue;
 | 
			
		||||
                 }
 | 
			
		||||
 | 
			
		||||
                 // @todo the getXpath kind of sucks, it doesnt know when there is for example just one ID sometimes
 | 
			
		||||
                 // it should not traverse when we know we can anchor off just an ID one level up etc..
 | 
			
		||||
                 // maybe, get current class or id, keep traversing up looking for only class or id until there is just one match 
 | 
			
		||||
 | 
			
		||||
                 // 1st primitive - if it has class, try joining it all and select, if theres only one.. well thats us.
 | 
			
		||||
                 xpath_result=false;
 | 
			
		||||
                 
 | 
			
		||||
                 try {
 | 
			
		||||
                   var d= findUpTag(elements[i]);
 | 
			
		||||
                   if (d) {
 | 
			
		||||
                     xpath_result =d;
 | 
			
		||||
                   }                
 | 
			
		||||
                 } catch (e) {
 | 
			
		||||
                   var x=1;
 | 
			
		||||
                 }
 | 
			
		||||
                 
 | 
			
		||||
// You could swap it and default to getXpath and then try the smarter one
 | 
			
		||||
                 // default back to the less intelligent one
 | 
			
		||||
                 if (!xpath_result) {
 | 
			
		||||
                   xpath_result = getXPath(elements[i]);                   
 | 
			
		||||
                 }
 | 
			
		||||
                 if(window.getComputedStyle(elements[i]).visibility === "hidden") {
 | 
			
		||||
                   continue;
 | 
			
		||||
                 }
 | 
			
		||||
 | 
			
		||||
                 size_pos.push({
 | 
			
		||||
                   xpath: xpath_result,
 | 
			
		||||
                   width: Math.round(bbox['width']), 
 | 
			
		||||
                   height: Math.round(bbox['height']), 
 | 
			
		||||
                   left: Math.floor(bbox['left']), 
 | 
			
		||||
                   top: Math.floor(bbox['top']), 
 | 
			
		||||
                   childCount: elements[i].childElementCount
 | 
			
		||||
                 });                 
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                // inject the current one set in the css_filter, which may be a CSS rule
 | 
			
		||||
                // used for displaying the current one in VisualSelector, where its not one we generated.
 | 
			
		||||
                if (css_filter.length) {
 | 
			
		||||
                   // is it xpath?
 | 
			
		||||
                   if (css_filter.startsWith('/') ) {
 | 
			
		||||
                     q=document.evaluate(css_filter, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
 | 
			
		||||
                   } else {
 | 
			
		||||
                     q=document.querySelector(css_filter);
 | 
			
		||||
                   }
 | 
			
		||||
                   bbox = q.getBoundingClientRect();                
 | 
			
		||||
                   if (bbox && bbox['width'] >0 && bbox['height']>0) {                       
 | 
			
		||||
                       size_pos.push({
 | 
			
		||||
                           xpath: css_filter,
 | 
			
		||||
                           width: bbox['width'], 
 | 
			
		||||
                           height: bbox['height'],
 | 
			
		||||
                           left: bbox['left'],
 | 
			
		||||
                           top: bbox['top'],
 | 
			
		||||
                           childCount: q.childElementCount
 | 
			
		||||
                         });
 | 
			
		||||
                     }
 | 
			
		||||
                }
 | 
			
		||||
// https://stackoverflow.com/questions/1145850/how-to-get-height-of-entire-document-with-javascript
 | 
			
		||||
                return {'size_pos':size_pos, 'browser_width': window.innerWidth, 'browser_height':document.body.scrollHeight};
 | 
			
		||||
    """
 | 
			
		||||
    xpath_data = None
 | 
			
		||||
 | 
			
		||||
    # Will be needed in the future by the VisualSelector, always get this where possible.
 | 
			
		||||
    screenshot = False
 | 
			
		||||
    fetcher_description = "No description"
 | 
			
		||||
@@ -47,7 +158,8 @@ class Fetcher():
 | 
			
		||||
            request_headers,
 | 
			
		||||
            request_body,
 | 
			
		||||
            request_method,
 | 
			
		||||
            ignore_status_codes=False):
 | 
			
		||||
            ignore_status_codes=False,
 | 
			
		||||
            current_css_filter=None):
 | 
			
		||||
        # Should set self.error, self.status_code and self.content
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
@@ -128,7 +240,8 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
            request_headers,
 | 
			
		||||
            request_body,
 | 
			
		||||
            request_method,
 | 
			
		||||
            ignore_status_codes=False):
 | 
			
		||||
            ignore_status_codes=False,
 | 
			
		||||
            current_css_filter=None):
 | 
			
		||||
 | 
			
		||||
        from playwright.sync_api import sync_playwright
 | 
			
		||||
        import playwright._impl._api_types
 | 
			
		||||
@@ -148,8 +261,8 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
                proxy=self.proxy
 | 
			
		||||
            )
 | 
			
		||||
            page = context.new_page()
 | 
			
		||||
            page.set_viewport_size({"width": 1280, "height": 1024})
 | 
			
		||||
            try:
 | 
			
		||||
               # Bug - never set viewport size BEFORE page.goto
 | 
			
		||||
                response = page.goto(url, timeout=timeout * 1000, wait_until='commit')
 | 
			
		||||
                # Wait_until = commit
 | 
			
		||||
                # - `'commit'` - consider operation to be finished when network response is received and the document started loading.
 | 
			
		||||
@@ -166,14 +279,27 @@ class base_html_playwright(Fetcher):
 | 
			
		||||
            if len(page.content().strip()) == 0:
 | 
			
		||||
                raise EmptyReply(url=url, status_code=None)
 | 
			
		||||
 | 
			
		||||
            # Bug 2(?) Set the viewport size AFTER loading the page
 | 
			
		||||
            page.set_viewport_size({"width": 1280, "height": 1024})
 | 
			
		||||
            # Bugish - Let the page redraw/reflow
 | 
			
		||||
            page.set_viewport_size({"width": 1280, "height": 1024})
 | 
			
		||||
 | 
			
		||||
            self.status_code = response.status
 | 
			
		||||
            self.content = page.content()
 | 
			
		||||
            self.headers = response.all_headers()
 | 
			
		||||
 | 
			
		||||
            if current_css_filter is not None:
 | 
			
		||||
                page.evaluate("var css_filter='{}'".format(current_css_filter))
 | 
			
		||||
            else:
 | 
			
		||||
                page.evaluate("var css_filter=''")
 | 
			
		||||
 | 
			
		||||
            self.xpath_data = page.evaluate("async () => {" + self.xpath_element_js + "}")
 | 
			
		||||
            # Bug 3 in Playwright screenshot handling
 | 
			
		||||
            # Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
 | 
			
		||||
            # JPEG is better here because the screenshots can be very very large
 | 
			
		||||
            page.screenshot(type='jpeg', clip={'x': 1.0, 'y': 1.0, 'width': 1280, 'height': 1024})
 | 
			
		||||
            self.screenshot = page.screenshot(type='jpeg', full_page=True, quality=90)
 | 
			
		||||
            self.screenshot = page.screenshot(type='jpeg', full_page=True, quality=92)
 | 
			
		||||
 | 
			
		||||
            context.close()
 | 
			
		||||
            browser.close()
 | 
			
		||||
 | 
			
		||||
@@ -225,7 +351,8 @@ class base_html_webdriver(Fetcher):
 | 
			
		||||
            request_headers,
 | 
			
		||||
            request_body,
 | 
			
		||||
            request_method,
 | 
			
		||||
            ignore_status_codes=False):
 | 
			
		||||
            ignore_status_codes=False,
 | 
			
		||||
            current_css_filter=None):
 | 
			
		||||
 | 
			
		||||
        from selenium import webdriver
 | 
			
		||||
        from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
 | 
			
		||||
@@ -245,6 +372,10 @@ class base_html_webdriver(Fetcher):
 | 
			
		||||
            self.quit()
 | 
			
		||||
            raise
 | 
			
		||||
 | 
			
		||||
        self.driver.set_window_size(1280, 1024)
 | 
			
		||||
        self.driver.implicitly_wait(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)))
 | 
			
		||||
        self.screenshot = self.driver.get_screenshot_as_png()
 | 
			
		||||
 | 
			
		||||
        # @todo - how to check this? is it possible?
 | 
			
		||||
        self.status_code = 200
 | 
			
		||||
        # @todo somehow we should try to get this working for WebDriver
 | 
			
		||||
@@ -254,8 +385,6 @@ class base_html_webdriver(Fetcher):
 | 
			
		||||
        time.sleep(int(os.getenv("WEBDRIVER_DELAY_BEFORE_CONTENT_READY", 5)) + self.render_extract_delay)
 | 
			
		||||
        self.content = self.driver.page_source
 | 
			
		||||
        self.headers = {}
 | 
			
		||||
        self.screenshot = self.driver.get_screenshot_as_png()
 | 
			
		||||
        self.quit()
 | 
			
		||||
 | 
			
		||||
    # Does the connection to the webdriver work? run a test connection.
 | 
			
		||||
    def is_ready(self):
 | 
			
		||||
@@ -292,7 +421,8 @@ class html_requests(Fetcher):
 | 
			
		||||
            request_headers,
 | 
			
		||||
            request_body,
 | 
			
		||||
            request_method,
 | 
			
		||||
            ignore_status_codes=False):
 | 
			
		||||
            ignore_status_codes=False,
 | 
			
		||||
            current_css_filter=None):
 | 
			
		||||
 | 
			
		||||
        proxies={}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -94,6 +94,7 @@ class perform_site_check():
 | 
			
		||||
            # If the klass doesnt exist, just use a default
 | 
			
		||||
            klass = getattr(content_fetcher, "html_requests")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        proxy_args = self.set_proxy_from_list(watch)
 | 
			
		||||
        fetcher = klass(proxy_override=proxy_args)
 | 
			
		||||
 | 
			
		||||
@@ -104,7 +105,8 @@ class perform_site_check():
 | 
			
		||||
        elif system_webdriver_delay is not None:
 | 
			
		||||
            fetcher.render_extract_delay = system_webdriver_delay
 | 
			
		||||
 | 
			
		||||
        fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_code)
 | 
			
		||||
        fetcher.run(url, timeout, request_headers, request_body, request_method, ignore_status_code, watch['css_filter'])
 | 
			
		||||
        fetcher.quit()
 | 
			
		||||
 | 
			
		||||
        # Fetching complete, now filters
 | 
			
		||||
        # @todo move to class / maybe inside of fetcher abstract base?
 | 
			
		||||
@@ -236,4 +238,4 @@ class perform_site_check():
 | 
			
		||||
                if not watch['title'] or not len(watch['title']):
 | 
			
		||||
                    update_obj['title'] = html_tools.extract_element(find='title', html_content=fetcher.content)
 | 
			
		||||
 | 
			
		||||
        return changed_detected, update_obj, text_content_before_ignored_filter, fetcher.screenshot
 | 
			
		||||
        return changed_detected, update_obj, text_content_before_ignored_filter, fetcher.screenshot, fetcher.xpath_data
 | 
			
		||||
 
 | 
			
		||||
@@ -22,3 +22,26 @@ echo "RUNNING WITH BASE_URL SET"
 | 
			
		||||
export BASE_URL="https://really-unique-domain.io"
 | 
			
		||||
pytest tests/test_notification.py
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Now for the selenium and playwright/browserless fetchers
 | 
			
		||||
# Note - this is not UI functional tests - just checking that each one can fetch the content
 | 
			
		||||
 | 
			
		||||
echo "TESTING WEBDRIVER FETCH > SELENIUM/WEBDRIVER..."
 | 
			
		||||
docker run -d --name $$-test_selenium  -p 4444:4444 --rm --shm-size="2g"  selenium/standalone-chrome-debug:3.141.59
 | 
			
		||||
# takes a while to spin up
 | 
			
		||||
sleep 5
 | 
			
		||||
export WEBDRIVER_URL=http://localhost:4444/wd/hub
 | 
			
		||||
pytest tests/fetchers/test_content.py
 | 
			
		||||
unset WEBDRIVER_URL
 | 
			
		||||
docker kill $$-test_selenium
 | 
			
		||||
 | 
			
		||||
echo "TESTING WEBDRIVER FETCH > PLAYWRIGHT/BROWSERLESS..."
 | 
			
		||||
# Not all platforms support playwright (not ARM/rPI), so it's not packaged in requirements.txt
 | 
			
		||||
pip3 install playwright~=1.22
 | 
			
		||||
docker run -d --name $$-test_browserless -e "DEFAULT_LAUNCH_ARGS=[\"--window-size=1920,1080\"]" --rm  -p 3000:3000  --shm-size="2g"  browserless/chrome:1.53-chrome-stable
 | 
			
		||||
# takes a while to spin up
 | 
			
		||||
sleep 5
 | 
			
		||||
export PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:3000
 | 
			
		||||
pytest tests/fetchers/test_content.py
 | 
			
		||||
unset PLAYWRIGHT_DRIVER_URL
 | 
			
		||||
docker kill $$-test_browserless
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/images/Playwright-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								changedetectionio/static/images/Playwright-icon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 6.2 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								changedetectionio/static/images/beta-logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								changedetectionio/static/images/beta-logo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 12 KiB  | 
							
								
								
									
										56
									
								
								changedetectionio/static/js/limit.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								changedetectionio/static/js/limit.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,56 @@
 | 
			
		||||
/**
 | 
			
		||||
 * debounce
 | 
			
		||||
 * @param {integer} milliseconds This param indicates the number of milliseconds
 | 
			
		||||
 *     to wait after the last call before calling the original function.
 | 
			
		||||
 * @param {object} What "this" refers to in the returned function.
 | 
			
		||||
 * @return {function} This returns a function that when called will wait the
 | 
			
		||||
 *     indicated number of milliseconds after the last call before
 | 
			
		||||
 *     calling the original function.
 | 
			
		||||
 */
 | 
			
		||||
Function.prototype.debounce = function (milliseconds, context) {
 | 
			
		||||
    var baseFunction = this,
 | 
			
		||||
        timer = null,
 | 
			
		||||
        wait = milliseconds;
 | 
			
		||||
 | 
			
		||||
    return function () {
 | 
			
		||||
        var self = context || this,
 | 
			
		||||
            args = arguments;
 | 
			
		||||
 | 
			
		||||
        function complete() {
 | 
			
		||||
            baseFunction.apply(self, args);
 | 
			
		||||
            timer = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (timer) {
 | 
			
		||||
            clearTimeout(timer);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        timer = setTimeout(complete, wait);
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
* throttle
 | 
			
		||||
* @param {integer} milliseconds This param indicates the number of milliseconds
 | 
			
		||||
*     to wait between calls before calling the original function.
 | 
			
		||||
* @param {object} What "this" refers to in the returned function.
 | 
			
		||||
* @return {function} This returns a function that when called will wait the
 | 
			
		||||
*     indicated number of milliseconds between calls before
 | 
			
		||||
*     calling the original function.
 | 
			
		||||
*/
 | 
			
		||||
Function.prototype.throttle = function (milliseconds, context) {
 | 
			
		||||
    var baseFunction = this,
 | 
			
		||||
        lastEventTimestamp = null,
 | 
			
		||||
        limit = milliseconds;
 | 
			
		||||
 | 
			
		||||
    return function () {
 | 
			
		||||
        var self = context || this,
 | 
			
		||||
            args = arguments,
 | 
			
		||||
            now = Date.now();
 | 
			
		||||
 | 
			
		||||
        if (!lastEventTimestamp || now - lastEventTimestamp >= limit) {
 | 
			
		||||
            lastEventTimestamp = now;
 | 
			
		||||
            baseFunction.apply(self, args);
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										219
									
								
								changedetectionio/static/js/visual-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								changedetectionio/static/js/visual-selector.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,219 @@
 | 
			
		||||
// Horrible proof of concept code :)
 | 
			
		||||
// yes - this is really a hack, if you are a front-ender and want to help, please get in touch!
 | 
			
		||||
 | 
			
		||||
$(document).ready(function() {
 | 
			
		||||
 | 
			
		||||
    $('#visualselector-tab').click(function () {
 | 
			
		||||
        $("img#selector-background").off('load');
 | 
			
		||||
        bootstrap_visualselector();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $(document).on('keydown', function(event) {
 | 
			
		||||
        if ($("img#selector-background").is(":visible")) {
 | 
			
		||||
            if (event.key == "Escape") {
 | 
			
		||||
                state_clicked=false;
 | 
			
		||||
                ctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // For when the page loads
 | 
			
		||||
    if(!window.location.hash || window.location.hash != '#visualselector') {
 | 
			
		||||
        $("img#selector-background").attr('src','');
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Handle clearing button/link
 | 
			
		||||
    $('#clear-selector').on('click', function(event) {
 | 
			
		||||
        if(!state_clicked) {
 | 
			
		||||
            alert('Oops, Nothing selected!');
 | 
			
		||||
        }
 | 
			
		||||
        state_clicked=false;
 | 
			
		||||
        ctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    bootstrap_visualselector();
 | 
			
		||||
 | 
			
		||||
    var current_selected_i;
 | 
			
		||||
    var state_clicked=false;
 | 
			
		||||
 | 
			
		||||
    var c;
 | 
			
		||||
 | 
			
		||||
    // greyed out fill context
 | 
			
		||||
    var xctx;
 | 
			
		||||
    // redline highlight context
 | 
			
		||||
    var ctx;
 | 
			
		||||
 | 
			
		||||
    var current_default_xpath;
 | 
			
		||||
    var x_scale=1;
 | 
			
		||||
    var y_scale=1;
 | 
			
		||||
    var selector_image;
 | 
			
		||||
    var selector_image_rect;
 | 
			
		||||
    var vh;
 | 
			
		||||
    var selector_data;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    function bootstrap_visualselector() {
 | 
			
		||||
        if ( 1 ) {
 | 
			
		||||
            // bootstrap it, this will trigger everything else
 | 
			
		||||
            $("img#selector-background").bind('load', function () {
 | 
			
		||||
                console.log("Loaded background...");
 | 
			
		||||
               c = document.getElementById("selector-canvas");
 | 
			
		||||
                // greyed out fill context
 | 
			
		||||
               xctx = c.getContext("2d");
 | 
			
		||||
                // redline highlight context
 | 
			
		||||
               ctx = c.getContext("2d");
 | 
			
		||||
               current_default_xpath =$("#css_filter").val();
 | 
			
		||||
               fetch_data();
 | 
			
		||||
               $('#selector-canvas').off("mousemove");
 | 
			
		||||
               // screenshot_url defined in the edit.html template
 | 
			
		||||
            }).attr("src", screenshot_url);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function fetch_data() {
 | 
			
		||||
      // Image is ready
 | 
			
		||||
      $('.fetching-update-notice').html("Fetching element data..");
 | 
			
		||||
 | 
			
		||||
      $.ajax({
 | 
			
		||||
        url: watch_visual_selector_data_url,
 | 
			
		||||
        context: document.body
 | 
			
		||||
      }).done(function (data) {
 | 
			
		||||
        $('.fetching-update-notice').html("Rendering..");
 | 
			
		||||
        selector_data = data;
 | 
			
		||||
        console.log("Reported browser width from backend: "+data['browser_width']);
 | 
			
		||||
        state_clicked=false;
 | 
			
		||||
        set_scale();
 | 
			
		||||
        reflow_selector();
 | 
			
		||||
        $('.fetching-update-notice').fadeOut();
 | 
			
		||||
      });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    function set_scale() {
 | 
			
		||||
 | 
			
		||||
      // some things to check if the scaling doesnt work
 | 
			
		||||
      // - that the widths/sizes really are about the actual screen size cat elements.json |grep -o width......|sort|uniq
 | 
			
		||||
      selector_image = $("img#selector-background")[0];
 | 
			
		||||
      selector_image_rect = selector_image.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
      // make the canvas the same size as the image
 | 
			
		||||
      $('#selector-canvas').attr('height', selector_image_rect.height);
 | 
			
		||||
      $('#selector-canvas').attr('width', selector_image_rect.width);
 | 
			
		||||
      $('#selector-wrapper').attr('width', selector_image_rect.width);
 | 
			
		||||
      x_scale = selector_image_rect.width / selector_data['browser_width'];
 | 
			
		||||
      y_scale = selector_image_rect.height / selector_image.naturalHeight;
 | 
			
		||||
      ctx.strokeStyle = 'rgba(255,0,0, 0.9)';
 | 
			
		||||
      ctx.fillStyle = 'rgba(255,0,0, 0.1)';
 | 
			
		||||
      ctx.lineWidth = 3;
 | 
			
		||||
      console.log("scaling set  x: "+x_scale+" by y:"+y_scale);
 | 
			
		||||
      $("#selector-current-xpath").css('max-width', selector_image_rect.width);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function reflow_selector() {
 | 
			
		||||
        $(window).resize(function() {
 | 
			
		||||
            set_scale();
 | 
			
		||||
            highlight_current_selected_i();
 | 
			
		||||
        });
 | 
			
		||||
      var selector_currnt_xpath_text=$("#selector-current-xpath span");
 | 
			
		||||
 | 
			
		||||
      set_scale();
 | 
			
		||||
 | 
			
		||||
      console.log(selector_data['size_pos'].length + " selectors found");
 | 
			
		||||
 | 
			
		||||
      // highlight the default one if we can find it in the xPath list
 | 
			
		||||
      // or the xpath matches the default one
 | 
			
		||||
      found = false;
 | 
			
		||||
      if(current_default_xpath.length) {
 | 
			
		||||
          for (var i = selector_data['size_pos'].length; i!==0; i--) {
 | 
			
		||||
            var sel = selector_data['size_pos'][i-1];
 | 
			
		||||
            if(selector_data['size_pos'][i - 1].xpath == current_default_xpath) {
 | 
			
		||||
            console.log("highlighting "+current_default_xpath);
 | 
			
		||||
              current_selected_i = i-1;
 | 
			
		||||
              highlight_current_selected_i();
 | 
			
		||||
              found = true;
 | 
			
		||||
              break;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        if(!found) {
 | 
			
		||||
          alert("unfortunately your existing CSS/xPath Filter was no longer found!");
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      $('#selector-canvas').bind('mousemove', function (e) {
 | 
			
		||||
        if(state_clicked) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        ctx.clearRect(0, 0, c.width, c.height);
 | 
			
		||||
        current_selected_i=null;
 | 
			
		||||
 | 
			
		||||
        // Reverse order - the most specific one should be deeper/"laster"
 | 
			
		||||
        // Basically, find the most 'deepest'
 | 
			
		||||
        var found=0;
 | 
			
		||||
        ctx.fillStyle = 'rgba(205,0,0,0.35)';
 | 
			
		||||
        for (var i = selector_data['size_pos'].length; i!==0; i--) {
 | 
			
		||||
          // draw all of them? let them choose somehow?
 | 
			
		||||
          var sel = selector_data['size_pos'][i-1];
 | 
			
		||||
          // If we are in a bounding-box
 | 
			
		||||
          if (e.offsetY > sel.top * y_scale && e.offsetY < sel.top * y_scale + sel.height * y_scale
 | 
			
		||||
              &&
 | 
			
		||||
              e.offsetX > sel.left * y_scale && e.offsetX < sel.left * y_scale + sel.width * y_scale
 | 
			
		||||
 | 
			
		||||
          ) {
 | 
			
		||||
 | 
			
		||||
            // FOUND ONE
 | 
			
		||||
            set_current_selected_text(sel.xpath);
 | 
			
		||||
            ctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
 | 
			
		||||
            ctx.fillRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
 | 
			
		||||
 | 
			
		||||
            // no need to keep digging
 | 
			
		||||
            // @todo or, O to go out/up, I to go in
 | 
			
		||||
            // or double click to go up/out the selector?
 | 
			
		||||
            current_selected_i=i-1;
 | 
			
		||||
            found+=1;
 | 
			
		||||
            break;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      }.debounce(5));
 | 
			
		||||
 | 
			
		||||
      function set_current_selected_text(s) {
 | 
			
		||||
        selector_currnt_xpath_text[0].innerHTML=s;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function highlight_current_selected_i() {
 | 
			
		||||
        if(state_clicked) {
 | 
			
		||||
          state_clicked=false;
 | 
			
		||||
          xctx.clearRect(0,0,c.width, c.height);
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var sel = selector_data['size_pos'][current_selected_i];
 | 
			
		||||
        if (sel[0] == '/') {
 | 
			
		||||
        // @todo - not sure just checking / is right
 | 
			
		||||
            $("#css_filter").val('xpath:'+sel.xpath);
 | 
			
		||||
        } else {
 | 
			
		||||
            $("#css_filter").val(sel.xpath);
 | 
			
		||||
        }
 | 
			
		||||
        xctx.fillStyle = 'rgba(205,205,205,0.95)';
 | 
			
		||||
        xctx.strokeStyle = 'rgba(225,0,0,0.9)';
 | 
			
		||||
        xctx.lineWidth = 3;
 | 
			
		||||
        xctx.fillRect(0,0,c.width, c.height);
 | 
			
		||||
        // Clear out what only should be seen (make a clear/clean spot)
 | 
			
		||||
        xctx.clearRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
 | 
			
		||||
        xctx.strokeRect(sel.left * x_scale, sel.top * y_scale, sel.width * x_scale, sel.height * y_scale);
 | 
			
		||||
        state_clicked=true;
 | 
			
		||||
        set_current_selected_text(sel.xpath);
 | 
			
		||||
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      $('#selector-canvas').bind('mousedown', function (e) {
 | 
			
		||||
        highlight_current_selected_i();
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
@@ -4,6 +4,7 @@ $(function () {
 | 
			
		||||
    $(this).closest('.unviewed').removeClass('unviewed');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  $('.with-share-link > *').click(function () {
 | 
			
		||||
      $("#copied-clipboard").remove();
 | 
			
		||||
 | 
			
		||||
@@ -20,5 +21,6 @@ $(function () {
 | 
			
		||||
       $(this).remove();
 | 
			
		||||
      });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -338,7 +338,8 @@ footer {
 | 
			
		||||
    padding-top: 110px; }
 | 
			
		||||
  div.tabs.collapsable ul li {
 | 
			
		||||
    display: block;
 | 
			
		||||
    border-radius: 0px; }
 | 
			
		||||
    border-radius: 0px;
 | 
			
		||||
    margin-right: 0px; }
 | 
			
		||||
  input[type='text'] {
 | 
			
		||||
    width: 100%; }
 | 
			
		||||
  /*
 | 
			
		||||
@@ -429,6 +430,15 @@ and also iPads specifically.
 | 
			
		||||
  .tab-pane-inner:target {
 | 
			
		||||
    display: block; }
 | 
			
		||||
 | 
			
		||||
#beta-logo {
 | 
			
		||||
  height: 50px;
 | 
			
		||||
  right: -3px;
 | 
			
		||||
  top: -3px;
 | 
			
		||||
  position: absolute; }
 | 
			
		||||
 | 
			
		||||
#selector-header {
 | 
			
		||||
  padding-bottom: 1em; }
 | 
			
		||||
 | 
			
		||||
.edit-form {
 | 
			
		||||
  min-width: 70%;
 | 
			
		||||
  /* so it cant overflow */
 | 
			
		||||
@@ -454,6 +464,24 @@ ul {
 | 
			
		||||
  .time-check-widget tr input[type="number"] {
 | 
			
		||||
    width: 5em; }
 | 
			
		||||
 | 
			
		||||
#selector-wrapper {
 | 
			
		||||
  height: 600px;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  position: relative; }
 | 
			
		||||
  #selector-wrapper > img {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    z-index: 4;
 | 
			
		||||
    max-width: 100%; }
 | 
			
		||||
  #selector-wrapper > canvas {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    z-index: 5;
 | 
			
		||||
    max-width: 100%; }
 | 
			
		||||
    #selector-wrapper > canvas:hover {
 | 
			
		||||
      cursor: pointer; }
 | 
			
		||||
 | 
			
		||||
#selector-current-xpath {
 | 
			
		||||
  font-size: 80%; }
 | 
			
		||||
 | 
			
		||||
#webdriver-override-options input[type="number"] {
 | 
			
		||||
  width: 5em; }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -469,6 +469,7 @@ footer {
 | 
			
		||||
  div.tabs.collapsable ul li {
 | 
			
		||||
    display: block;
 | 
			
		||||
    border-radius: 0px;
 | 
			
		||||
    margin-right: 0px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input[type='text'] {
 | 
			
		||||
@@ -613,6 +614,18 @@ $form-edge-padding: 20px;
 | 
			
		||||
    padding: 0px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#beta-logo {
 | 
			
		||||
    height: 50px;
 | 
			
		||||
    // looks better when it's hanging off a little
 | 
			
		||||
    right: -3px;
 | 
			
		||||
    top: -3px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#selector-header {
 | 
			
		||||
    padding-bottom: 1em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.edit-form {
 | 
			
		||||
  min-width: 70%;
 | 
			
		||||
  /* so it cant overflow */
 | 
			
		||||
@@ -649,6 +662,30 @@ ul {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#selector-wrapper {
 | 
			
		||||
 height: 600px;
 | 
			
		||||
 overflow-y: scroll;
 | 
			
		||||
 position: relative;
 | 
			
		||||
    //width: 100%;
 | 
			
		||||
 > img {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    z-index: 4;
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
 }
 | 
			
		||||
 >canvas {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    z-index: 5;
 | 
			
		||||
     max-width: 100%;
 | 
			
		||||
     &:hover {
 | 
			
		||||
     cursor: pointer;
 | 
			
		||||
     }
 | 
			
		||||
 }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#selector-current-xpath {
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#webdriver-override-options {
 | 
			
		||||
        input[type="number"] {
 | 
			
		||||
            width: 5em;
 | 
			
		||||
 
 | 
			
		||||
@@ -372,6 +372,15 @@ class ChangeDetectionStore:
 | 
			
		||||
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def visualselector_data_is_ready(self, watch_uuid):
 | 
			
		||||
        output_path = "{}/{}".format(self.datastore_path, watch_uuid)
 | 
			
		||||
        screenshot_filename = "{}/last-screenshot.png".format(output_path)
 | 
			
		||||
        elements_index_filename = "{}/elements.json".format(output_path)
 | 
			
		||||
        if path.isfile(screenshot_filename) and  path.isfile(elements_index_filename) :
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    # Save as PNG, PNG is larger but better for doing visual diff in the future
 | 
			
		||||
    def save_screenshot(self, watch_uuid, screenshot: bytes):
 | 
			
		||||
        output_path = "{}/{}".format(self.datastore_path, watch_uuid)
 | 
			
		||||
@@ -380,6 +389,14 @@ class ChangeDetectionStore:
 | 
			
		||||
            f.write(screenshot)
 | 
			
		||||
            f.close()
 | 
			
		||||
 | 
			
		||||
    def save_xpath_data(self, watch_uuid, data):
 | 
			
		||||
        output_path = "{}/{}".format(self.datastore_path, watch_uuid)
 | 
			
		||||
        fname = "{}/elements.json".format(output_path)
 | 
			
		||||
        with open(fname, 'w') as f:
 | 
			
		||||
            f.write(json.dumps(data))
 | 
			
		||||
            f.close()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def sync_to_json(self):
 | 
			
		||||
        logging.info("Saving JSON..")
 | 
			
		||||
        print("Saving JSON..")
 | 
			
		||||
 
 | 
			
		||||
@@ -39,9 +39,6 @@
 | 
			
		||||
<div class="tabs">
 | 
			
		||||
    <ul>
 | 
			
		||||
        <li class="tab" id="default-tab"><a href="#text">Text</a></li>
 | 
			
		||||
{% if screenshot %}
 | 
			
		||||
        <li class="tab"><a href="#screenshot">Current screenshot</a></li>
 | 
			
		||||
{% endif %}
 | 
			
		||||
    </ul>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@@ -63,18 +60,6 @@
 | 
			
		||||
         </table>
 | 
			
		||||
         Diff algorithm from the amazing <a href="https://github.com/kpdecker/jsdiff">github.com/kpdecker/jsdiff</a>
 | 
			
		||||
     </div>
 | 
			
		||||
 | 
			
		||||
{% if screenshot %}
 | 
			
		||||
     <div class="tab-pane-inner" id="screenshot">
 | 
			
		||||
         <p>
 | 
			
		||||
             <i>For now, only the most recent screenshot is saved and displayed.</i></br>
 | 
			
		||||
             <strong>Note: No changedetection is performed on the image yet, but we are working on that in an upcoming release.</strong>
 | 
			
		||||
         </p>
 | 
			
		||||
 | 
			
		||||
         <img src="{{url_for('static_content', group='screenshot', filename=uuid)}}">
 | 
			
		||||
     </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,12 +5,18 @@
 | 
			
		||||
<script type="text/javascript" src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
 | 
			
		||||
<script>
 | 
			
		||||
    const notification_base_url="{{url_for('ajax_callback_send_notification_test')}}";
 | 
			
		||||
    const watch_visual_selector_data_url="{{url_for('static_content', group='visual_selector_data', filename=uuid)}}";
 | 
			
		||||
    const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}";
 | 
			
		||||
 | 
			
		||||
{% if emailprefix %}
 | 
			
		||||
    const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}');
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
<script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
 | 
			
		||||
<script type="text/javascript" src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
 | 
			
		||||
<script type="text/javascript" src="{{url_for('static_content', group='js', filename='visual-selector.js')}}" defer></script>
 | 
			
		||||
<script type="text/javascript" src="{{url_for('static_content', group='js', filename='limit.js')}}" defer></script>
 | 
			
		||||
 | 
			
		||||
<div class="edit-form monospaced-textarea">
 | 
			
		||||
 | 
			
		||||
@@ -18,6 +24,7 @@
 | 
			
		||||
        <ul>
 | 
			
		||||
            <li class="tab" id="default-tab"><a href="#general">General</a></li>
 | 
			
		||||
            <li class="tab"><a href="#request">Request</a></li>
 | 
			
		||||
            <li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Selector</a></li>
 | 
			
		||||
            <li class="tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
 | 
			
		||||
            <li class="tab"><a href="#notifications">Notifications</a></li>
 | 
			
		||||
        </ul>
 | 
			
		||||
@@ -194,6 +201,46 @@ nav
 | 
			
		||||
                </fieldset>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="tab-pane-inner visual-selector-ui" id="visualselector">
 | 
			
		||||
                <img id="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}">
 | 
			
		||||
 | 
			
		||||
                <fieldset>
 | 
			
		||||
                    <div class="pure-control-group">
 | 
			
		||||
                        {% if visualselector_enabled %}
 | 
			
		||||
                            {% if visualselector_data_is_ready %}
 | 
			
		||||
                                <div id="selector-header">
 | 
			
		||||
                                    <a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a>
 | 
			
		||||
                                    <i class="fetching-update-notice" style="font-size: 80%;">One moment, fetching screenshot and element information..</i>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                <div id="selector-wrapper">
 | 
			
		||||
                                    <!-- request the screenshot and get the element offset info ready -->
 | 
			
		||||
                                    <!-- use img src ready load to know everything is ready to map out -->
 | 
			
		||||
                                    <!-- @todo: maybe something interesting like a field to select 'elements that contain text... and their parents n' -->
 | 
			
		||||
                                    <img id="selector-background" />
 | 
			
		||||
                                    <canvas id="selector-canvas"></canvas>
 | 
			
		||||
 | 
			
		||||
                                </div>
 | 
			
		||||
                                <div id="selector-current-xpath" style="overflow-x: hidden"><strong>Currently:</strong> <span class="text">Loading...</span></div>
 | 
			
		||||
 | 
			
		||||
                            <span class="pure-form-message-inline">
 | 
			
		||||
                                <p><span style="font-weight: bold">Beta!</span> The Visual Selector is new and there may be minor bugs, please report pages that dont work, help us to improve this software!</p>
 | 
			
		||||
                            </span>
 | 
			
		||||
 | 
			
		||||
                            {% else %}
 | 
			
		||||
                                <span class="pure-form-message-inline">Screenshot and element data is not available or not yet ready.</span>
 | 
			
		||||
                            {% endif %}
 | 
			
		||||
                        {% else %}
 | 
			
		||||
                            <span class="pure-form-message-inline">
 | 
			
		||||
                                <p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p>
 | 
			
		||||
                                <p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p>
 | 
			
		||||
                                <p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p>
 | 
			
		||||
 | 
			
		||||
                            </span>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                    </div>
 | 
			
		||||
                </fieldset>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div id="actions">
 | 
			
		||||
                <div class="pure-control-group">
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,9 +10,6 @@
 | 
			
		||||
<div class="tabs">
 | 
			
		||||
    <ul>
 | 
			
		||||
        <li class="tab" id="default-tab"><a href="#text">Text</a></li>
 | 
			
		||||
{% if screenshot %}
 | 
			
		||||
        <li class="tab"><a href="#screenshot">Current screenshot</a></li>
 | 
			
		||||
{% endif %}
 | 
			
		||||
    </ul>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@@ -31,16 +28,5 @@
 | 
			
		||||
            </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% if screenshot %}
 | 
			
		||||
     <div class="tab-pane-inner" id="screenshot">
 | 
			
		||||
         <p>
 | 
			
		||||
             <i>For now, only the most recent screenshot is saved and displayed.</i></br>
 | 
			
		||||
             <strong>Note: No changedetection is performed on the image yet, but we are working on that in an upcoming release.</strong>
 | 
			
		||||
         </p>
 | 
			
		||||
 | 
			
		||||
        <img src="{{url_for('static_content', group='screenshot', filename=uuid)}}">
 | 
			
		||||
     </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -3,6 +3,7 @@
 | 
			
		||||
{% from '_helpers.jinja' import render_simple_field %}
 | 
			
		||||
<script type="text/javascript" src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
 | 
			
		||||
<script type="text/javascript" src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
 | 
			
		||||
 | 
			
		||||
<div class="box">
 | 
			
		||||
 | 
			
		||||
    <form class="pure-form" action="{{ url_for('form_watch_add') }}" method="POST" id="new-watch-form">
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								changedetectionio/tests/fetchers/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								changedetectionio/tests/fetchers/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
"""Tests for the app."""
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								changedetectionio/tests/fetchers/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changedetectionio/tests/fetchers/conftest.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
 | 
			
		||||
from .. import conftest
 | 
			
		||||
							
								
								
									
										48
									
								
								changedetectionio/tests/fetchers/test_content.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								changedetectionio/tests/fetchers/test_content.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
#!/usr/bin/python3
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from ..util import live_server_setup
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_fetch_webdriver_content(client, live_server):
 | 
			
		||||
    live_server_setup(live_server)
 | 
			
		||||
 | 
			
		||||
    #####################
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("settings_page"),
 | 
			
		||||
        data={"application-empty_pages_are_a_change": "",
 | 
			
		||||
              "requests-time_between_check-minutes": 180,
 | 
			
		||||
              'application-fetch_backend': "html_webdriver"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"Settings updated." in res.data
 | 
			
		||||
 | 
			
		||||
    # Add our URL to the import page
 | 
			
		||||
    res = client.post(
 | 
			
		||||
        url_for("import_page"),
 | 
			
		||||
        data={"urls": "https://changedetection.io/ci-test.html"},
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert b"1 Imported" in res.data
 | 
			
		||||
    time.sleep(3)
 | 
			
		||||
    attempt = 0
 | 
			
		||||
    while attempt < 20:
 | 
			
		||||
        res = client.get(url_for("index"))
 | 
			
		||||
        if not b'Checking now' in res.data:
 | 
			
		||||
            break
 | 
			
		||||
        logging.getLogger().info("Waiting for check to not say 'Checking now'..")
 | 
			
		||||
        time.sleep(3)
 | 
			
		||||
        attempt += 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    res = client.get(
 | 
			
		||||
        url_for("preview_page", uuid="first"),
 | 
			
		||||
        follow_redirects=True
 | 
			
		||||
    )
 | 
			
		||||
    logging.getLogger().info("Looking for correct fetched HTML (text) from server")
 | 
			
		||||
 | 
			
		||||
    assert b'cool it works' in res.data
 | 
			
		||||
@@ -121,7 +121,7 @@ def test_trigger_functionality(client, live_server):
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' not in res.data
 | 
			
		||||
 | 
			
		||||
    # Just to be sure.. set a regular modified change..
 | 
			
		||||
    # Now set the content which contains the trigger text
 | 
			
		||||
    time.sleep(sleep_time_for_fetch_thread)
 | 
			
		||||
    set_modified_with_trigger_text_response()
 | 
			
		||||
 | 
			
		||||
@@ -130,6 +130,12 @@ def test_trigger_functionality(client, live_server):
 | 
			
		||||
    res = client.get(url_for("index"))
 | 
			
		||||
    assert b'unviewed' in res.data
 | 
			
		||||
 | 
			
		||||
    # https://github.com/dgtlmoon/changedetection.io/issues/616
 | 
			
		||||
    # Apparently the actual snapshot that contains the trigger never shows
 | 
			
		||||
    res = client.get(url_for("diff_history_page", uuid="first"))
 | 
			
		||||
    assert b'foobar123' in res.data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    # Check the preview/highlighter, we should be able to see what we triggered on, but it should be highlighted
 | 
			
		||||
    res = client.get(url_for("preview_page", uuid="first"))
 | 
			
		||||
    # We should be able to see what we ignored
 | 
			
		||||
 
 | 
			
		||||
@@ -40,10 +40,11 @@ class update_worker(threading.Thread):
 | 
			
		||||
                    contents = ""
 | 
			
		||||
                    screenshot = False
 | 
			
		||||
                    update_obj= {}
 | 
			
		||||
                    xpath_data = False
 | 
			
		||||
                    now = time.time()
 | 
			
		||||
 | 
			
		||||
                    try:
 | 
			
		||||
                        changed_detected, update_obj, contents, screenshot = update_handler.run(uuid)
 | 
			
		||||
                        changed_detected, update_obj, contents, screenshot, xpath_data = update_handler.run(uuid)
 | 
			
		||||
 | 
			
		||||
                        # Re #342
 | 
			
		||||
                        # In Python 3, all strings are sequences of Unicode characters. There is a bytes type that holds raw bytes.
 | 
			
		||||
@@ -55,6 +56,7 @@ class update_worker(threading.Thread):
 | 
			
		||||
                    except content_fetcher.ReplyWithContentButNoText as e:
 | 
			
		||||
                        # Totally fine, it's by choice - just continue on, nothing more to care about
 | 
			
		||||
                        # Page had elements/content but no renderable text
 | 
			
		||||
                        self.datastore.update_watch(uuid=uuid, update_obj={'last_error': "Got HTML content but no text found."})
 | 
			
		||||
                        pass
 | 
			
		||||
                    except content_fetcher.EmptyReply as e:
 | 
			
		||||
                        # Some kind of custom to-str handler in the exception handler that does this?
 | 
			
		||||
@@ -148,6 +150,9 @@ class update_worker(threading.Thread):
 | 
			
		||||
                        # Always save the screenshot if it's available
 | 
			
		||||
                        if screenshot:
 | 
			
		||||
                            self.datastore.save_screenshot(watch_uuid=uuid, screenshot=screenshot)
 | 
			
		||||
                        if xpath_data:
 | 
			
		||||
                            self.datastore.save_xpath_data(watch_uuid=uuid, data=xpath_data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                self.current_uuid = None  # Done
 | 
			
		||||
                self.q.task_done()
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user