diff --git a/changedetectionio/content_fetchers/helpers.py b/changedetectionio/content_fetchers/helpers.py new file mode 100644 index 000000000..25b47234e --- /dev/null +++ b/changedetectionio/content_fetchers/helpers.py @@ -0,0 +1,101 @@ + +# Pages with a vertical height longer than this will use the 'stitch together' method. + +# - Many GPUs have a max texture size of 16384x16384px (or lower on older devices). +# - If a page is taller than ~8000–10000px, it risks exceeding GPU memory limits. +# - This is especially important on headless Chromium, where Playwright may fail to allocate a massive full-page buffer. + +SCREENSHOT_SIZE_STITCH_THRESHOLD=16000 +from loguru import logger + +def capture_stitched_together_full_page(page): + import io + import os + import time + from PIL import Image, ImageDraw, ImageFont + + MAX_TOTAL_HEIGHT = 12000 # Maximum total height for the final image + MAX_CHUNK_HEIGHT = 5000 # Height per screenshot chunk + WARNING_HEIGHT = 20 # Height of the warning text overlay + + # Save the original viewport size + original_viewport = page.viewport_size + now = time.time() + + try: + viewport = page.viewport_size + page_height = page.evaluate("document.documentElement.scrollHeight") + + # Limit the total capture height + capture_height = min(page_height, MAX_TOTAL_HEIGHT) + + images = [] + total_captured_height = 0 + + for offset in range(0, capture_height, MAX_CHUNK_HEIGHT): + # Ensure we do not exceed the total height limit + chunk_height = min(MAX_CHUNK_HEIGHT, MAX_TOTAL_HEIGHT - total_captured_height) + + # Adjust viewport size for this chunk + page.set_viewport_size({"width": viewport["width"], "height": chunk_height}) + + # Scroll to the correct position + page.evaluate(f"window.scrollTo(0, {offset})") + + # Capture screenshot chunk + screenshot_bytes = page.screenshot(type='jpeg', quality=int(os.getenv("SCREENSHOT_QUALITY", 30))) + images.append(Image.open(io.BytesIO(screenshot_bytes))) + + total_captured_height += chunk_height + + # Stop if we reached the maximum total height + if total_captured_height >= MAX_TOTAL_HEIGHT: + break + + # Create the final stitched image + stitched_image = Image.new('RGB', (viewport["width"], total_captured_height)) + y_offset = 0 + + # Stitch the screenshot chunks together + for img in images: + stitched_image.paste(img, (0, y_offset)) + y_offset += img.height + + logger.debug(f"Screenshot stitched together in {time.time()-now:.2f}s") + + # Overlay warning text if the screenshot was trimmed + if page_height > MAX_TOTAL_HEIGHT: + draw = ImageDraw.Draw(stitched_image) + warning_text = f"WARNING: Screenshot was {page_height}px but trimmed to {MAX_TOTAL_HEIGHT}px because it was too long" + + # Load font (default system font if Arial is unavailable) + try: + font = ImageFont.truetype("arial.ttf", 21) # Arial (Windows/Mac) + except IOError: + font = ImageFont.load_default() # Default font if Arial not found + + # Get text bounding box (correct method for newer Pillow versions) + text_bbox = draw.textbbox((0, 0), warning_text, font=font) + text_width = text_bbox[2] - text_bbox[0] # Calculate text width + text_height = text_bbox[3] - text_bbox[1] # Calculate text height + + # Define background rectangle (top of the image) + draw.rectangle([(0, 0), (viewport["width"], WARNING_HEIGHT)], fill="white") + + # Center text horizontally within the warning area + text_x = (viewport["width"] - text_width) // 2 + text_y = (WARNING_HEIGHT - text_height) // 2 + + # Draw the warning text in red + draw.text((text_x, text_y), warning_text, fill="red", font=font) + + # Save or return the final image + output = io.BytesIO() + stitched_image.save(output, format="JPEG", quality=int(os.getenv("SCREENSHOT_QUALITY", 30))) + screenshot = output.getvalue() + + finally: + # Restore the original viewport size + page.set_viewport_size(original_viewport) + + return screenshot diff --git a/changedetectionio/content_fetchers/res/xpath_element_scraper.js b/changedetectionio/content_fetchers/res/xpath_element_scraper.js index ccd894369..d514a2064 100644 --- a/changedetectionio/content_fetchers/res/xpath_element_scraper.js +++ b/changedetectionio/content_fetchers/res/xpath_element_scraper.js @@ -41,7 +41,7 @@ const findUpTag = (el) => { // Strategy 1: If it's an input, with name, and there's only one, prefer that if (el.name !== undefined && el.name.length) { - var proposed = el.tagName + "[name=" + el.name + "]"; + var proposed = el.tagName + "[name=\"" + CSS.escape(el.name) + "\"]"; var proposed_element = window.document.querySelectorAll(proposed); if (proposed_element.length) { if (proposed_element.length === 1) {