Files
dgtlmoonandClaude Opus 5.5 44fd71679e Puppeteer fetcher - PDF improvements, Handle PDF fetching by Puppeteer directly, cleanup supports (#4547)
* Fetchers - Define raw_content on the base fetcher, give a clear error when a fetcher can't supply the PDF

Fetchers that never set raw_content (puppeteer, playwright, selenium) crashed with
"'fetcher' object has no attribute 'raw_content'" when the response turned out to be a PDF.
Browsers render PDFs in their built-in viewer, so all they have is the viewer HTML; now the
processor reports that plainly instead of crashing or feeding HTML to pdftohtml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Puppeteer - Capture the real PDF body when the page turns out to be a PDF

Chrome hands a PDF straight to its built-in viewer, after which page.content and
Network.getResponseBody only return the viewer's HTML. Pause main-frame document responses
with the CDP Fetch domain and, when the Content-Type is application/pdf, copy the body out
before it is continued - a single download, and nothing needs to be known in advance.

content/raw_content then match the requests fetcher's binary handling (md5 + original bytes)
so the existing PDF processing and checksum skip work unchanged. Normal HTML pages are
continued immediately, no measurable difference in fetch time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetchers - Add supports_pdf capability, only force html_requests for PDFs when the fetcher can't supply them

PDF watches were always forced onto html_requests. Now that puppeteer can capture the original
PDF (useful when the session needs a real browser, Cloudflare etc), only fetchers without
supports_pdf fall back - both at fetch time (resolve_content_fetcher) and in Watch.get_fetch_backend.

Also save the content-type when a fetcher couldn't supply the PDF (it was only saved on success),
otherwise watch.is_pdf never became true for URLs not ending in .pdf and the watch failed on
every check instead of falling back to html_requests on the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:50:43 +02:00

200 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
from changedetectionio.strtobool import strtobool
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'
# Import hookimpl from centralized pluggy interface
from changedetectionio.pluggy_interface import hookimpl
SCREENSHOT_MAX_HEIGHT_DEFAULT = 20000
SCREENSHOT_DEFAULT_QUALITY = 40
# Maximum total height for the final image (When in stitch mode).
# We limit this to 16000px due to the huge amount of RAM that was being used
# Example: 16000 × 1400 × 3 = 67,200,000 bytes ≈ 64.1 MB (not including buffers in PIL etc)
SCREENSHOT_MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
# The size at which we will switch to stitching method, when below this (and
# MAX_TOTAL_HEIGHT which can be set by a user) we will use the default
# screenshot method.
# Increased from 8000 to 10000 for better performance (fewer chunks = faster)
# Most modern GPUs support 16384x16384 textures, so 1280x10000 is safe
SCREENSHOT_SIZE_STITCH_THRESHOLD = int(os.getenv("SCREENSHOT_CHUNK_HEIGHT", 10000))
# available_fetchers() will scan this implementation looking for anything starting with html_
# this information is used in the form selections
from changedetectionio.content_fetchers.requests import fetcher as html_requests
import importlib.resources
XPATH_ELEMENT_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('xpath_element_scraper.js').read_text(encoding='utf-8')
INSTOCK_DATA_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('stock-not-in-stock.js').read_text(encoding='utf-8')
FAVICON_FETCHER_JS = importlib.resources.files("changedetectionio.content_fetchers.res").joinpath('favicon-fetcher.js').read_text(encoding='utf-8')
def available_fetchers():
# See the if statement at the bottom of this file for how we switch between playwright and webdriver
import inspect
p = []
# Get built-in fetchers (but skip plugin fetchers that were added via setattr)
for name, obj in inspect.getmembers(sys.modules[__name__], inspect.isclass):
if inspect.isclass(obj):
# @todo html_ is maybe better as fetcher_ or something
# In this case, make sure to edit the default one in store.py and fetch_site_status.py
if name.startswith('html_'):
# Skip plugin fetchers that were already registered
if name not in _plugin_fetchers:
t = tuple([name, obj.fetcher_description])
p.append(t)
# Get plugin fetchers from cache (already loaded at module init)
for name, fetcher_class in _plugin_fetchers.items():
if hasattr(fetcher_class, 'fetcher_description'):
t = tuple([name, fetcher_class.fetcher_description])
p.append(t)
else:
logger.warning(f"Plugin fetcher '{name}' does not have fetcher_description attribute")
return p
def get_plugin_fetchers():
"""Load and return all plugin fetchers from the centralized plugin manager."""
from changedetectionio.pluggy_interface import plugin_manager
fetchers = {}
try:
# Call the register_content_fetcher hook from all registered plugins
results = plugin_manager.hook.register_content_fetcher()
for result in results:
if result:
name, fetcher_class = result
fetchers[name] = fetcher_class
# Register in current module so hasattr() checks work
setattr(sys.modules[__name__], name, fetcher_class)
logger.info(f"Registered plugin fetcher: {name} - {getattr(fetcher_class, 'fetcher_description', 'No description')}")
except Exception as e:
logger.error(f"Error loading plugin fetchers: {e}")
return fetchers
# Initialize plugins at module load time
_plugin_fetchers = get_plugin_fetchers()
def _log_fetcher_capabilities(fetcher_class, backend_name, uuid=None):
"""logger.info the capabilities of a resolved content fetcher class.
Returns the FetcherCapabilities instance so callers can reuse it.
"""
from changedetectionio.content_fetchers.base import FetcherCapabilities
caps = FetcherCapabilities.from_fetcher(fetcher_class)
logger.info(
f"Content fetcher '{backend_name}' ({getattr(fetcher_class, 'fetcher_description', 'No description')})"
f"{f' for watch {uuid}' if uuid else ''} - capabilities: "
f"browser_steps={caps.supports_browser_steps}, "
f"screenshots={caps.supports_screenshots}, "
f"xpath_element_data={caps.supports_xpath_element_data}, "
f"pdf={caps.supports_pdf}"
)
return caps
def resolve_content_fetcher(watch, datastore):
"""Single source of truth for resolving which content fetcher a watch should use.
Resolution order (room to grow):
1. Watch-level `fetch_backend`
2. (future) group-level default
3. System/global default from application settings
Also collapses the special backend forms into a concrete fetcher:
- 'system' -> global application default
- 'extra_browser_<key>' -> html_webdriver + custom connection URL
- is_pdf watch -> html_requests, unless the fetcher has supports_pdf
- html_webdriver + browser_steps -> playwright override (puppeteer steps incomplete)
Returns:
tuple: (fetcher_class, backend_name, custom_browser_connection_url)
where `backend_name` is the fully-resolved concrete backend name the
caller should stamp onto the fetcher instance as `.backend_name`.
"""
this_module = sys.modules[__name__]
# 1. Watch preference (later: watch -> group -> system)
prefer_fetch_backend = watch.get('fetch_backend', 'system')
# 2/3. Fall back to the global/system default
if not prefer_fetch_backend or prefer_fetch_backend == 'system':
prefer_fetch_backend = datastore.data['settings']['application'].get('fetch_backend')
# Custom browser endpoint (extra_browser_<key>) -> webdriver with a specific connection URL
custom_browser_connection_url = None
if prefer_fetch_backend and prefer_fetch_backend.startswith('extra_browser_'):
(t, key) = prefer_fetch_backend.split('extra_browser_')
connection = list(
filter(lambda s: (s['browser_name'] == key),
datastore.data['settings']['requests'].get('extra_browsers', [])))
if connection:
prefer_fetch_backend = 'html_webdriver'
custom_browser_connection_url = connection[0].get('browser_connection_url')
# Grab the right kind of 'fetcher' class (playwright, requests, plugin-provided, etc)
if prefer_fetch_backend and hasattr(this_module, prefer_fetch_backend):
# @todo TEMPORARY HACK - SWITCH BACK TO PLAYWRIGHT FOR BROWSERSTEPS
if prefer_fetch_backend == 'html_webdriver' and getattr(watch, 'has_browser_steps', False):
# This is never supported in selenium anyway
logger.warning(
"Using playwright fetcher override for possible puppeteer request in browsersteps, "
"because puppetteer:browser steps is incomplete.")
from changedetectionio.content_fetchers.playwright import fetcher as playwright_fetcher
fetcher_obj = playwright_fetcher
else:
fetcher_obj = getattr(this_module, prefer_fetch_backend)
else:
# What it referenced doesn't exist, just use a default
fetcher_obj = getattr(this_module, "html_requests")
# Browsers show a PDF in their built-in viewer, only some fetchers can get the original PDF out of it
# @todo https://github.com/dgtlmoon/changedetection.io/issues/2019
if getattr(watch, 'is_pdf', False) and not getattr(fetcher_obj, 'supports_pdf', False):
logger.warning(
f"Watch {watch.get('uuid')} is_pdf detected (content-type/url) - forcing the "
f"'html_requests' fetcher because '{prefer_fetch_backend}' can't supply the original PDF."
)
prefer_fetch_backend = "html_requests"
fetcher_obj = getattr(this_module, "html_requests")
_log_fetcher_capabilities(fetcher_obj, prefer_fetch_backend, uuid=watch.get('uuid'))
return fetcher_obj, prefer_fetch_backend, custom_browser_connection_url
# Decide which is the 'real' HTML webdriver, this is more a system wide config
# rather than site-specific.
use_playwright_as_chrome_fetcher = os.getenv('PLAYWRIGHT_DRIVER_URL', False)
if use_playwright_as_chrome_fetcher:
# @note - For now, browser steps always uses playwright
if not strtobool(os.getenv('FAST_PUPPETEER_CHROME_FETCHER', 'False')):
logger.debug('Using Playwright library as fetcher')
from .playwright import fetcher as html_webdriver
else:
logger.debug('Using direct Python Puppeteer library as fetcher')
from .puppeteer import fetcher as html_webdriver
else:
logger.debug("Falling back to selenium as fetcher")
from .webdriver_selenium import fetcher as html_webdriver
# Register built-in fetchers as plugins after all imports are complete
from changedetectionio.pluggy_interface import register_builtin_fetchers
register_builtin_fetchers()