diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 99c7915df..407df32d2 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -1281,6 +1281,12 @@ TextResponse objects :class:`TextResponse` objects support the following methods in addition to the standard :class:`Response` ones: + .. method:: TextResponse.jmespath(query) + + A shortcut to ``TextResponse.selector.jmespath(query)``:: + + response.jmespath('object.[*]') + .. method:: TextResponse.xpath(query) A shortcut to ``TextResponse.selector.xpath(query)``:: diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index 4213d491d..a82ed834a 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -142,6 +142,12 @@ class Response(object_ref): """ raise NotSupported("Response content isn't text") + def jmespath(self, *a, **kw): + """Shortcut method implemented only by responses whose content + is text (subclasses of TextResponse). + """ + raise NotSupported("Response content isn't text") + def xpath(self, *a, **kw): """Shortcut method implemented only by responses whose content is text (subclasses of TextResponse). diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index d580a7876..5289f014a 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -141,6 +141,14 @@ class TextResponse(Response): self._cached_selector = Selector(self) return self._cached_selector + def jmespath(self, query, **kwargs): + if not hasattr(self.selector, "jmespath"): # type: ignore[attr-defined] + raise AttributeError( + "Please install parsel >= 1.8.1 to get jmespath support" + ) + + return self.selector.jmespath(query, **kwargs) # type: ignore[attr-defined] + def xpath(self, query, **kwargs): return self.selector.xpath(query, **kwargs) diff --git a/tests/test_http_response.py b/tests/test_http_response.py index a05b702aa..54f0461e8 100644 --- a/tests/test_http_response.py +++ b/tests/test_http_response.py @@ -32,6 +32,9 @@ class BaseResponseTest(unittest.TestCase): isinstance(self.response_class("http://example.com/"), self.response_class) ) self.assertRaises(TypeError, self.response_class, b"http://example.com") + self.assertRaises( + TypeError, self.response_class, url="http://example.com", body={} + ) # body can be str or None self.assertTrue( isinstance( @@ -192,6 +195,7 @@ class BaseResponseTest(unittest.TestCase): self.assertRaisesRegex(AttributeError, msg, getattr, r, "text") self.assertRaisesRegex(NotSupported, msg, r.css, "body") self.assertRaisesRegex(NotSupported, msg, r.xpath, "//body") + self.assertRaisesRegex(NotSupported, msg, r.jmespath, "body") else: r.text r.css("body") diff --git a/tests/test_selector.py b/tests/test_selector.py index febae46ac..85527bba9 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -1,10 +1,16 @@ import weakref +import parsel +import pytest +from packaging import version from twisted.trial import unittest from scrapy.http import HtmlResponse, TextResponse, XmlResponse from scrapy.selector import Selector +PARSEL_VERSION = version.parse(getattr(parsel, "__version__", "0.0")) +PARSEL_18_PLUS = PARSEL_VERSION >= version.parse("1.8.0") + class SelectorTestCase(unittest.TestCase): def test_simple_selection(self): @@ -108,3 +114,162 @@ class SelectorTestCase(unittest.TestCase): def test_selector_bad_args(self): with self.assertRaisesRegex(ValueError, "received both response and text"): Selector(TextResponse(url="http://example.com", body=b""), text="") + + +class JMESPathTestCase(unittest.TestCase): + @pytest.mark.skipif( + not PARSEL_18_PLUS, reason="parsel < 1.8 doesn't support jmespath" + ) + def test_json_has_html(self) -> None: + """Sometimes the information is returned in a json wrapper""" + + body = """ + { + "content": [ + { + "name": "A", + "value": "a" + }, + { + "name": { + "age": 18 + }, + "value": "b" + }, + { + "name": "C", + "value": "c" + }, + { + "name": "D", + "value": "
d
" + } + ], + "html": "
a
b
c
def
" + } + """ + resp = TextResponse(url="http://example.com", body=body, encoding="utf-8") + self.assertEqual( + resp.jmespath("html").get(), + "
a
b
c
def
", + ) + self.assertEqual( + resp.jmespath("html").xpath("//div/a/text()").getall(), + ["a", "b", "d"], + ) + self.assertEqual(resp.jmespath("html").css("div > b").getall(), ["f"]) + self.assertEqual(resp.jmespath("content").jmespath("name.age").get(), "18") + + @pytest.mark.skipif( + not PARSEL_18_PLUS, reason="parsel < 1.8 doesn't support jmespath" + ) + def test_html_has_json(self) -> None: + body = """ +
+

Information

+ + { + "user": [ + { + "name": "A", + "age": 18 + }, + { + "name": "B", + "age": 32 + }, + { + "name": "C", + "age": 22 + }, + { + "name": "D", + "age": 25 + } + ], + "total": 4, + "status": "ok" + } + +
+ """ + resp = TextResponse(url="http://example.com", body=body, encoding="utf-8") + self.assertEqual( + resp.xpath("//div/content/text()").jmespath("user[*].name").getall(), + ["A", "B", "C", "D"], + ) + self.assertEqual( + resp.xpath("//div/content").jmespath("user[*].name").getall(), + ["A", "B", "C", "D"], + ) + self.assertEqual(resp.xpath("//div/content").jmespath("total").get(), "4") + + @pytest.mark.skipif( + not PARSEL_18_PLUS, reason="parsel < 1.8 doesn't support jmespath" + ) + def test_jmestpath_with_re(self) -> None: + body = """ +
+

Information

+ + { + "user": [ + { + "name": "A", + "age": 18 + }, + { + "name": "B", + "age": 32 + }, + { + "name": "C", + "age": 22 + }, + { + "name": "D", + "age": 25 + } + ], + "total": 4, + "status": "ok" + } + +
+ """ + resp = TextResponse(url="http://example.com", body=body, encoding="utf-8") + self.assertEqual( + resp.xpath("//div/content/text()").jmespath("user[*].name").re(r"(\w+)"), + ["A", "B", "C", "D"], + ) + self.assertEqual( + resp.xpath("//div/content").jmespath("user[*].name").re(r"(\w+)"), + ["A", "B", "C", "D"], + ) + + self.assertEqual( + resp.xpath("//div/content").jmespath("unavailable").re(r"(\d+)"), [] + ) + + self.assertEqual( + resp.xpath("//div/content").jmespath("unavailable").re_first(r"(\d+)"), + None, + ) + + self.assertEqual( + resp.xpath("//div/content") + .jmespath("user[*].age.to_string(@)") + .re(r"(\d+)"), + ["18", "32", "22", "25"], + ) + + @pytest.mark.skipif(PARSEL_18_PLUS, reason="parsel >= 1.8 supports jmespath") + def test_jmespath_not_available(my_json_page) -> None: + body = """ + { + "website": {"name": "Example"} + } + """ + resp = TextResponse(url="http://example.com", body=body, encoding="utf-8") + with pytest.raises(AttributeError): + resp.jmespath("website.name").get()