from itertools import chain from pathlib import Path from time import process_time from urllib.parse import urlparse import pytest from xtractmime import BINARY_BYTES, RESOURCE_HEADER_BUFFER_LENGTH from scrapy.http import HtmlResponse, JsonResponse, Response, TextResponse, XmlResponse from scrapy.http.headers import Headers from scrapy.responsetypes import ResponseTypes from scrapy.utils.misc import load_object from scrapy.utils.python import to_bytes from scrapy.utils.response import ( _get_encoding_or_mime_type_from_headers, _remove_html_comments, get_base_url, get_meta_refresh, get_response_class, open_in_browser, response_status_message, ) def _read_browser_output(burl: str): path = urlparse(burl).path if not path or not Path(path).exists(): path = burl.replace("file://", "") return Path(path).read_bytes() # https://mimesniff.spec.whatwg.org/#interpreting-the-resource-metadata PRE_XTRACTMIME_HTML_STARTS = ( b" bytes: """Make odd bytes lowecase and even bytes uppercase. >>> odd_capitalize(b'foobar') b'fOoBaR' """ return b"".join( bytes([byte]).lower() if index % 2 == 0 else bytes([byte]).upper() for index, byte in enumerate(value) ) # Scenarios that work the same with the previously-used, deprecated # scrapy.responsetypes.responsetypes.from_args PRE_XTRACTMIME_SCENARIOS = ( # Content-Type determines the type for the HTTP protocol. *( ( { "url": f"{protocol}://example.com/foo", "headers": Headers( {"Content-Type": content_type + content_type_parameters} ), }, response_class, ) for protocol in ("http", "https") # Make sure that MIME parameters do not break response class choice. for content_type_parameters in ("", "; foo=bar") for content_type, response_class in ( ("text/plain", TextResponse), ("text/html", HtmlResponse), ("text/xml", XmlResponse), *( (mime_type, load_object(class_path)) for mime_type, class_path in ResponseTypes.CLASSES.items() if mime_type not in ( # “Note that XHTML is best parsed as XML” # https://lxml.de/parsing.html "application/xhtml+xml", "application/vnd.wap.xhtml+xml", ) ), # JavaScript MIME types should trigger a TextResponse. # # https://mimesniff.spec.whatwg.org/#javascript-mime-type *( (mime_type, TextResponse) for mime_type in ( "application/javascript", "application/x-javascript", "text/ecmascript", "text/javascript", "text/javascript1.0", "text/javascript1.1", "text/javascript1.2", "text/javascript1.3", "text/javascript1.4", "text/javascript1.5", "text/jscript", "text/livescript", "text/x-ecmascript", "text/x-javascript", # Unofficial "application/x-javascript", ) ), # JSON MIME types should trigger a JsonResponse. # # https://mimesniff.spec.whatwg.org/#json-mime-type *( (mime_type, JsonResponse) for mime_type in ( "application/json", # Unofficial "application/json-amazonui-streaming", "application/x-json", ) ), ) ), # Content-Type triumphs body, except for: # # - Binary content mislabeled as plain text due to an Apache bug # https://mimesniff.spec.whatwg.org/#check-for-apache-bug-flag # https://mimesniff.spec.whatwg.org/#rules-for-text-or-binary # # - Feeds mislabeled as HTML # https://mimesniff.spec.whatwg.org/#rules-for-distinguishing-if-a-resource-is-a-feed-or-html *( ( { "body": body, "headers": Headers({"Content-Type": [content_type]}), }, response_class, ) for body, content_type, response_class in ( *( (b"\x00\x01\xff", content_type, TextResponse) for content_type in ( # text/plain variants *not* affected by the Apache bug "text/plain; charset=Iso-8859-1", "text/plain; charset=utf-8", "text/plain; charset=windows-1252", ) ), ) ), # Content-Type triumphs Content-Disposition. *( ( { "url": f"{protocol}://example.com/a", "headers": Headers( { "Content-Disposition": [ f'attachment; filename="a.{file_extension}"', ], "Content-Type": [content_type], } ), }, response_class, ) for protocol in ("http", "https") for file_extension, content_type, response_class in ( ("html", "application/json", JsonResponse), ("xml", "application/json", JsonResponse), ) ), # Compressed content should be of type Response until uncompressed. ( { "headers": Headers( { "Content-Encoding": ["zip"], "Content-Type": ["text/html"], } ) }, Response, ), # We take the file extension of URL paths into account, except for HTTP # responses, because “they are unreliable and easily spoofed”. # # https://mimesniff.spec.whatwg.org/#interpreting-the-resource-metadata *( ( {"url": f"{protocol}://example.com/a.{extension}"}, response_class, ) for protocol in ("file", "ftp") for extension, response_class in ( ("gz", Response), ("html", HtmlResponse), ("json", JsonResponse), ("pdf", Response), ("txt", TextResponse), ("xml", XmlResponse), ) ), # Unlike in a web browser, where an attachment Content-Disposition header # causes the response to be downloaded, and hence MIME sniffing becomes # irrelevant, in Scrapy those responses are handled the same as any, and # hence we take the file extension from Content-Disposition into account # to choose a response class, as a fallback when there is no Content-Type. *( ( { "url": f"{protocol}://example.com/a", "headers": Headers( { "Content-Disposition": [ 'attachment; filename="a.xml"', ] } ), }, XmlResponse, ) for protocol in ("http", "https") ), *( ( { "url": f"{protocol}://example.com/a", "headers": Headers( { "Content-Disposition": [ 'attachment; filename="a.html"', ], "Content-Type": "text/xml", } ), }, XmlResponse, ) for protocol in ("http", "https") ), *( ( { "url": f"{protocol}://example.com/a", "body": b"Hello", HtmlResponse), (b'\n.", HtmlResponse), # https://mimesniff.spec.whatwg.org/#identifying-a-resource-with-an-unknown-mime-type *( (prefix + start + b">", HtmlResponse) for prefix in ( b"", *(byte for byte in WHITESPACE_BYTES if byte != b"\x0c"), ) for start in ( set_case(start) for set_case in (bytes.lower, bytes.upper, odd_capitalize) for start in PRE_XTRACTMIME_HTML_STARTS ) ), *( (prefix + b"", "headers": Headers( { "Content-Encoding": ["zip"], } ), }, Response, ), # If the body is empty, it contains no binary data bytes, hence body-based # MIME type detection must interpret the result as text. # # https://mimesniff.spec.whatwg.org/#identifying-a-resource-with-an-unknown-mime-type ({}, TextResponse), # We take the file extension of URL paths into account, except for HTTP # responses, because “they are unreliable and easily spoofed”. # # https://mimesniff.spec.whatwg.org/#interpreting-the-resource-metadata *( ( {"url": f"{protocol}://example.com/a.{extension}"}, response_class, ) for protocol in ("file", "ftp") for extension, response_class in ( # “Note that XHTML is best parsed as XML” # https://lxml.de/parsing.html ("xhtml", XmlResponse), ) ), *( ( {"url": f"{protocol}://example.com/a.html"}, response_class, ) for protocol, response_class in ( *((protocol, TextResponse) for protocol in ("http", "https")), ) ), # File extension triumphs body. ( { "body": b"", "headers": Headers( { "Content-Disposition": [ 'attachment; filename="a.gz"', ], } ), }, Response, ), ( { "body": b"", "url": "file:///a.gz", }, Response, ), # Without anything else, the body determines the response class. *( ({"body": body}, response_class) for body, response_class in ( # https://mimesniff.spec.whatwg.org/#identifying-a-resource-with-an-unknown-mime-type *( (start + b">", HtmlResponse) for start in ( set_case(start) for set_case in (bytes.lower, bytes.upper, odd_capitalize) for start in POST_XTRACTMIME_HTML_STARTS ) ), *( (start + b" ", HtmlResponse) for start in ( set_case(start) for set_case in (bytes.lower, bytes.upper, odd_capitalize) for start in chain( PRE_XTRACTMIME_HTML_STARTS, POST_XTRACTMIME_HTML_STARTS, ) ) ), *( (b"\x0c" + start + b">", HtmlResponse) for start in ( set_case(start) for set_case in (bytes.lower, bytes.upper, odd_capitalize) for start in PRE_XTRACTMIME_HTML_STARTS ) ), (b"\x0c", TextResponse), (b"a test page test body " ) def browser_open(burl: str) -> bool: bbody = _read_browser_output(burl) assert b'' in bbody return True response = HtmlResponse(url, body=body) assert open_in_browser(response, _openfunc=browser_open), "Browser not called" resp = Response(url, body=body) with pytest.raises(TypeError): open_in_browser(resp, debug=True) # pylint: disable=unexpected-keyword-arg def test_get_meta_refresh(): r1 = HtmlResponse( "http://www.example.com", body=b""" Dummy blahablsdfsal& """, ) r2 = HtmlResponse( "http://www.example.com", body=b""" Dummy blahablsdfsal& """, ) r3 = HtmlResponse( "http://www.example.com", body=b"""