Skip to content

Commit 14db86a

Browse files
authored
Page method navigation result (#382)
* PageMethod: extract headers from last navigation * Update test HTML file * Paint it black * Fix test, remove redundant test * Update and simplify test * Use handler's from_crawler in test * Simplify check * Update readme
1 parent 2ad9a64 commit 14db86a

6 files changed

Lines changed: 82 additions & 19 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -959,10 +959,10 @@ to see available methods.
959959

960960
### Impact on Response objects
961961

962-
Certain `Response` attributes (e.g. `url`, `ip_address`) reflect the state after the last
963-
action performed on a page. If you issue a `PageMethod` with an action that results in
964-
a navigation (e.g. a `click` on a link), the `Response.url` attribute will point to the
965-
new URL, which might be different from the request's URL.
962+
Certain `Response` attributes (e.g. `url`, `ip_address`, `status`, `headers`) reflect the
963+
state after the last action performed on a page. If you issue a `PageMethod` with an action
964+
that results in a navigation (e.g. a `click` on a link), these attributes will point to the
965+
new page, which might be different from the request's URL.
966966

967967

968968
## Handling page events

scrapy_playwright/handler.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,13 @@ async def _download_request_with_page(
518518

519519
start_time = time()
520520
response, download = await self._get_response_and_download(request, page, spider)
521+
522+
# page methods may navigate the main frame away from the original response
523+
response = await self._maybe_apply_page_methods(
524+
page=page, request=request, spider=spider, response=response
525+
)
526+
527+
headers = Headers()
521528
if isinstance(response, PlaywrightResponse):
522529
await _set_redirect_meta(request=request, response=response)
523530
headers = Headers(await response.all_headers())
@@ -534,9 +541,7 @@ async def _download_request_with_page(
534541
"scrapy_request_method": request.method,
535542
},
536543
)
537-
headers = Headers()
538544

539-
await self._apply_page_methods(page, request, spider)
540545
body_str = await _get_page_content(
541546
page=page,
542547
spider=spider,
@@ -672,7 +677,51 @@ async def _handle_response(response: PlaywrightResponse) -> None:
672677

673678
return response, download if download else None
674679

675-
async def _apply_page_methods(self, page: Page, request: Request, spider: Spider) -> None:
680+
async def _maybe_apply_page_methods(
681+
self,
682+
page: Page,
683+
request: Request,
684+
spider: Spider,
685+
response: Optional[PlaywrightResponse],
686+
) -> Optional[PlaywrightResponse]:
687+
"""Run the request's page methods, returning the final Playwright response to use.
688+
689+
If a page method navigates the main frame away from the original response, the final Scrapy
690+
response should have updated URL, body, status and headers. URL and body can be taken from
691+
the Playwright Page, but status and headers need to be taken from the final Playwright
692+
Response, which is not available as return value of page.goto().
693+
"""
694+
if not request.meta.get("playwright_page_methods"):
695+
return response
696+
697+
# track the most recent main-frame document navigation triggered by the page methods
698+
last_navigation = response
699+
700+
def _track_navigation(navigation_response: PlaywrightResponse) -> None:
701+
nonlocal last_navigation
702+
if (
703+
navigation_response.frame is page.main_frame
704+
and navigation_response.request.is_navigation_request()
705+
and navigation_response.request.resource_type == "document"
706+
):
707+
last_navigation = navigation_response
708+
709+
page.on("response", _track_navigation)
710+
try:
711+
await self._run_page_methods(page, request, spider)
712+
finally:
713+
page.remove_listener("response", _track_navigation)
714+
715+
# use the final navigation response if it superseded the original one
716+
if (
717+
last_navigation is not response
718+
and isinstance(last_navigation, PlaywrightResponse)
719+
and last_navigation.url.rstrip("/") == page.url.rstrip("/")
720+
):
721+
return last_navigation
722+
return response
723+
724+
async def _run_page_methods(self, page: Page, request: Request, spider: Spider) -> None:
676725
context_name = request.meta.get("playwright_context")
677726
page_methods = request.meta.get("playwright_page_methods") or ()
678727
if isinstance(page_methods, dict):

tests/site/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<h1>Awesome site</h1>
1212
<p><a class="lorem_ipsum" href="lorem_ipsum.html">Lorem Ipsum</a></p>
1313
<p><a class="scroll" href="scroll.html">Infinite Scroll</a></p>
14+
<p><a class="json" href="data/quotes1.json">Quotes JSON</a></p>
1415
</div>
1516
</body>
1617
</html>

tests/tests_asyncio/test_page_methods.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import platform
34
import subprocess
@@ -6,7 +7,6 @@
67

78
import pytest
89
from scrapy import Spider, Request
9-
from scrapy.http.response.html import HtmlResponse
1010

1111
from playwright.async_api import Page
1212
from scrapy_playwright.page import PageMethod
@@ -79,24 +79,31 @@ async def test_mixed(self):
7979

8080
@allow_windows
8181
async def test_page_method_navigation(self):
82+
"""A PageMethod that navigates to a different page must produce a response
83+
whose headers and status match the final page's body, not the initial one.
84+
"""
8285
async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
8386
req = Request(
8487
url=self.static_server.urljoin("/index.html"),
8588
meta={
8689
"playwright": True,
87-
"playwright_page_methods": [PageMethod("click", "a.lorem_ipsum")],
90+
"playwright_page_methods": [PageMethod("click", "a.json")],
8891
},
8992
)
9093
resp = await handler._download_request(req, Spider("foo"))
9194

92-
assert isinstance(resp, HtmlResponse)
93-
assert resp.request is req
94-
assert resp.url == self.static_server.urljoin("/lorem_ipsum.html")
95-
assert resp.status == 200
96-
assert "playwright" in resp.flags
97-
assert resp.css("title::text").get() == "Lorem Ipsum"
98-
text = resp.css("p::text").get()
99-
assert text == "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
95+
assert resp.request is req
96+
assert resp.url == self.static_server.urljoin("/data/quotes1.json")
97+
assert resp.status == 200
98+
assert "playwright" in resp.flags
99+
# headers must match the final (JSON) page, not the initial HTML page
100+
assert resp.headers.get("Content-Type", b"").startswith(b"application/json")
101+
# parse body and verify it's JSON, not HTML
102+
body = json.loads(resp.css("pre::text").get())
103+
assert isinstance(body, dict)
104+
assert isinstance(body.get("quotes"), list)
105+
assert body.get("has_next") is True
106+
assert body.get("page") == 1
100107

101108
@allow_windows
102109
async def test_page_method_infinite_scroll(self):

tests/tests_asyncio/test_playwright_requests.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,12 @@ async def test_basic_response(self):
4444
req = Request(self.static_server.urljoin("/index.html"), meta={"playwright": True})
4545
resp = await handler._download_request(req, spider)
4646
assert_correct_response(resp, req)
47+
assert resp.css("a::text").getall() == [
48+
"Lorem Ipsum",
49+
"Infinite Scroll",
50+
"Quotes JSON",
51+
]
4752
assert resp.ip_address == ip_address(self.static_server.address)
48-
assert resp.css("a::text").getall() == ["Lorem Ipsum", "Infinite Scroll"]
4953

5054
# at least one log record has a spider attribute
5155
# (records sent before spider_opened will not have it)

tests/tests_twisted/test_mixed_requests.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ def tearDown(self):
3333
def test_download_request(self):
3434
def _check_response(response: Response, request: Request) -> None:
3535
self.assertIsInstance(response, Response)
36-
self.assertEqual(response.css("a::text").getall(), ["Lorem Ipsum", "Infinite Scroll"])
36+
self.assertEqual(
37+
response.css("a::text").getall(), ["Lorem Ipsum", "Infinite Scroll", "Quotes JSON"]
38+
)
3739
self.assertEqual(response.url, request.url)
3840
self.assertEqual(response.status, 200)
3941
if request.meta.get("playwright"):

0 commit comments

Comments
 (0)