scrapling 0.2.99__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. scrapling/__init__.py +18 -31
  2. scrapling/cli.py +818 -20
  3. scrapling/core/_html_utils.py +348 -0
  4. scrapling/core/_types.py +34 -17
  5. scrapling/core/ai.py +611 -0
  6. scrapling/core/custom_types.py +183 -100
  7. scrapling/core/mixins.py +27 -19
  8. scrapling/core/shell.py +647 -0
  9. scrapling/core/{storage_adaptors.py → storage.py} +41 -33
  10. scrapling/core/translator.py +20 -26
  11. scrapling/core/utils.py +49 -54
  12. scrapling/engines/__init__.py +15 -6
  13. scrapling/engines/_browsers/__init__.py +2 -0
  14. scrapling/engines/_browsers/_camoufox.py +759 -0
  15. scrapling/engines/_browsers/_config_tools.py +130 -0
  16. scrapling/engines/_browsers/_controllers.py +644 -0
  17. scrapling/engines/_browsers/_page.py +93 -0
  18. scrapling/engines/_browsers/_validators.py +170 -0
  19. scrapling/engines/constants.py +101 -88
  20. scrapling/engines/static.py +667 -110
  21. scrapling/engines/toolbelt/__init__.py +20 -6
  22. scrapling/engines/toolbelt/bypasses/playwright_fingerprint.js +2 -1
  23. scrapling/engines/toolbelt/convertor.py +254 -0
  24. scrapling/engines/toolbelt/custom.py +158 -175
  25. scrapling/engines/toolbelt/fingerprints.py +32 -46
  26. scrapling/engines/toolbelt/navigation.py +68 -39
  27. scrapling/fetchers.py +239 -333
  28. scrapling/parser.py +781 -449
  29. scrapling-0.3.1.dist-info/METADATA +411 -0
  30. scrapling-0.3.1.dist-info/RECORD +41 -0
  31. {scrapling-0.2.99.dist-info → scrapling-0.3.1.dist-info}/WHEEL +1 -1
  32. {scrapling-0.2.99.dist-info → scrapling-0.3.1.dist-info}/top_level.txt +0 -1
  33. scrapling/defaults.py +0 -25
  34. scrapling/engines/camo.py +0 -339
  35. scrapling/engines/pw.py +0 -465
  36. scrapling/engines/toolbelt/bypasses/pdf_viewer.js +0 -5
  37. scrapling-0.2.99.dist-info/METADATA +0 -290
  38. scrapling-0.2.99.dist-info/RECORD +0 -49
  39. tests/__init__.py +0 -1
  40. tests/fetchers/__init__.py +0 -1
  41. tests/fetchers/async/__init__.py +0 -0
  42. tests/fetchers/async/test_camoufox.py +0 -97
  43. tests/fetchers/async/test_httpx.py +0 -85
  44. tests/fetchers/async/test_playwright.py +0 -101
  45. tests/fetchers/sync/__init__.py +0 -0
  46. tests/fetchers/sync/test_camoufox.py +0 -70
  47. tests/fetchers/sync/test_httpx.py +0 -84
  48. tests/fetchers/sync/test_playwright.py +0 -89
  49. tests/fetchers/test_utils.py +0 -97
  50. tests/parser/__init__.py +0 -0
  51. tests/parser/test_automatch.py +0 -111
  52. tests/parser/test_general.py +0 -330
  53. {scrapling-0.2.99.dist-info → scrapling-0.3.1.dist-info}/entry_points.txt +0 -0
  54. {scrapling-0.2.99.dist-info → scrapling-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,70 +0,0 @@
1
- import pytest
2
- import pytest_httpbin
3
-
4
- from scrapling import StealthyFetcher
5
-
6
- StealthyFetcher.auto_match = True
7
-
8
-
9
- @pytest_httpbin.use_class_based_httpbin
10
- class TestStealthyFetcher:
11
- @pytest.fixture(scope="class")
12
- def fetcher(self):
13
- """Fixture to create a StealthyFetcher instance for the entire test class"""
14
- return StealthyFetcher
15
-
16
- @pytest.fixture(autouse=True)
17
- def setup_urls(self, httpbin):
18
- """Fixture to set up URLs for testing"""
19
- self.status_200 = f'{httpbin.url}/status/200'
20
- self.status_404 = f'{httpbin.url}/status/404'
21
- self.status_501 = f'{httpbin.url}/status/501'
22
- self.basic_url = f'{httpbin.url}/get'
23
- self.html_url = f'{httpbin.url}/html'
24
- self.delayed_url = f'{httpbin.url}/delay/10' # 10 Seconds delay response
25
- self.cookies_url = f"{httpbin.url}/cookies/set/test/value"
26
-
27
- def test_basic_fetch(self, fetcher):
28
- """Test doing basic fetch request with multiple statuses"""
29
- assert fetcher.fetch(self.status_200).status == 200
30
- assert fetcher.fetch(self.status_404).status == 404
31
- assert fetcher.fetch(self.status_501).status == 501
32
-
33
- def test_networkidle(self, fetcher):
34
- """Test if waiting for `networkidle` make page does not finish loading or not"""
35
- assert fetcher.fetch(self.basic_url, network_idle=True).status == 200
36
-
37
- def test_blocking_resources(self, fetcher):
38
- """Test if blocking resources make page does not finish loading or not"""
39
- assert fetcher.fetch(self.basic_url, block_images=True).status == 200
40
- assert fetcher.fetch(self.basic_url, disable_resources=True).status == 200
41
-
42
- def test_waiting_selector(self, fetcher):
43
- """Test if waiting for a selector make page does not finish loading or not"""
44
- assert fetcher.fetch(self.html_url, wait_selector='h1').status == 200
45
- assert fetcher.fetch(self.html_url, wait_selector='h1', wait_selector_state='visible').status == 200
46
-
47
- def test_cookies_loading(self, fetcher):
48
- """Test if cookies are set after the request"""
49
- assert fetcher.fetch(self.cookies_url).cookies == {'test': 'value'}
50
-
51
- def test_automation(self, fetcher):
52
- """Test if automation break the code or not"""
53
- def scroll_page(page):
54
- page.mouse.wheel(10, 0)
55
- page.mouse.move(100, 400)
56
- page.mouse.up()
57
- return page
58
-
59
- assert fetcher.fetch(self.html_url, page_action=scroll_page).status == 200
60
-
61
- def test_properties(self, fetcher):
62
- """Test if different arguments breaks the code or not"""
63
- assert fetcher.fetch(self.html_url, block_webrtc=True, allow_webgl=True).status == 200
64
- assert fetcher.fetch(self.html_url, block_webrtc=False, allow_webgl=True).status == 200
65
- assert fetcher.fetch(self.html_url, block_webrtc=True, allow_webgl=False).status == 200
66
- assert fetcher.fetch(self.html_url, extra_headers={'ayo': ''}, os_randomize=True).status == 200
67
-
68
- def test_infinite_timeout(self, fetcher):
69
- """Test if infinite timeout breaks the code or not"""
70
- assert fetcher.fetch(self.delayed_url, timeout=None).status == 200
@@ -1,84 +0,0 @@
1
- import pytest
2
- import pytest_httpbin
3
-
4
- from scrapling import Fetcher
5
-
6
- Fetcher.auto_match = True
7
-
8
-
9
- @pytest_httpbin.use_class_based_httpbin
10
- class TestFetcher:
11
- @pytest.fixture(scope="class")
12
- def fetcher(self):
13
- """Fixture to create a Fetcher instance for the entire test class"""
14
- return Fetcher
15
-
16
- @pytest.fixture(autouse=True)
17
- def setup_urls(self, httpbin):
18
- """Fixture to set up URLs for testing"""
19
- self.status_200 = f'{httpbin.url}/status/200'
20
- self.status_404 = f'{httpbin.url}/status/404'
21
- self.status_501 = f'{httpbin.url}/status/501'
22
- self.basic_url = f'{httpbin.url}/get'
23
- self.post_url = f'{httpbin.url}/post'
24
- self.put_url = f'{httpbin.url}/put'
25
- self.delete_url = f'{httpbin.url}/delete'
26
- self.html_url = f'{httpbin.url}/html'
27
-
28
- def test_basic_get(self, fetcher):
29
- """Test doing basic get request with multiple statuses"""
30
- assert fetcher.get(self.status_200).status == 200
31
- assert fetcher.get(self.status_404).status == 404
32
- assert fetcher.get(self.status_501).status == 501
33
-
34
- def test_get_properties(self, fetcher):
35
- """Test if different arguments with GET request breaks the code or not"""
36
- assert fetcher.get(self.status_200, stealthy_headers=True).status == 200
37
- assert fetcher.get(self.status_200, follow_redirects=True).status == 200
38
- assert fetcher.get(self.status_200, timeout=None).status == 200
39
- assert fetcher.get(
40
- self.status_200,
41
- stealthy_headers=True,
42
- follow_redirects=True,
43
- timeout=None
44
- ).status == 200
45
-
46
- def test_post_properties(self, fetcher):
47
- """Test if different arguments with POST request breaks the code or not"""
48
- assert fetcher.post(self.post_url, data={'key': 'value'}).status == 200
49
- assert fetcher.post(self.post_url, data={'key': 'value'}, stealthy_headers=True).status == 200
50
- assert fetcher.post(self.post_url, data={'key': 'value'}, follow_redirects=True).status == 200
51
- assert fetcher.post(self.post_url, data={'key': 'value'}, timeout=None).status == 200
52
- assert fetcher.post(
53
- self.post_url,
54
- data={'key': 'value'},
55
- stealthy_headers=True,
56
- follow_redirects=True,
57
- timeout=None
58
- ).status == 200
59
-
60
- def test_put_properties(self, fetcher):
61
- """Test if different arguments with PUT request breaks the code or not"""
62
- assert fetcher.put(self.put_url, data={'key': 'value'}).status == 200
63
- assert fetcher.put(self.put_url, data={'key': 'value'}, stealthy_headers=True).status == 200
64
- assert fetcher.put(self.put_url, data={'key': 'value'}, follow_redirects=True).status == 200
65
- assert fetcher.put(self.put_url, data={'key': 'value'}, timeout=None).status == 200
66
- assert fetcher.put(
67
- self.put_url,
68
- data={'key': 'value'},
69
- stealthy_headers=True,
70
- follow_redirects=True,
71
- timeout=None
72
- ).status == 200
73
-
74
- def test_delete_properties(self, fetcher):
75
- """Test if different arguments with DELETE request breaks the code or not"""
76
- assert fetcher.delete(self.delete_url, stealthy_headers=True).status == 200
77
- assert fetcher.delete(self.delete_url, follow_redirects=True).status == 200
78
- assert fetcher.delete(self.delete_url, timeout=None).status == 200
79
- assert fetcher.delete(
80
- self.delete_url,
81
- stealthy_headers=True,
82
- follow_redirects=True,
83
- timeout=None
84
- ).status == 200
@@ -1,89 +0,0 @@
1
- import pytest
2
- import pytest_httpbin
3
-
4
- from scrapling import PlayWrightFetcher
5
-
6
- PlayWrightFetcher.auto_match = True
7
-
8
-
9
- @pytest_httpbin.use_class_based_httpbin
10
- class TestPlayWrightFetcher:
11
-
12
- @pytest.fixture(scope="class")
13
- def fetcher(self):
14
- """Fixture to create a StealthyFetcher instance for the entire test class"""
15
- return PlayWrightFetcher
16
-
17
- @pytest.fixture(autouse=True)
18
- def setup_urls(self, httpbin):
19
- """Fixture to set up URLs for testing"""
20
- self.status_200 = f'{httpbin.url}/status/200'
21
- self.status_404 = f'{httpbin.url}/status/404'
22
- self.status_501 = f'{httpbin.url}/status/501'
23
- self.basic_url = f'{httpbin.url}/get'
24
- self.html_url = f'{httpbin.url}/html'
25
- self.delayed_url = f'{httpbin.url}/delay/10' # 10 Seconds delay response
26
- self.cookies_url = f"{httpbin.url}/cookies/set/test/value"
27
-
28
- def test_basic_fetch(self, fetcher):
29
- """Test doing basic fetch request with multiple statuses"""
30
- assert fetcher.fetch(self.status_200).status == 200
31
- # There's a bug with playwright makes it crashes if a URL returns status code 4xx/5xx without body, let's disable this till they reply to my issue report
32
- # assert fetcher.fetch(self.status_404).status == 404
33
- # assert fetcher.fetch(self.status_501).status == 501
34
-
35
- def test_networkidle(self, fetcher):
36
- """Test if waiting for `networkidle` make page does not finish loading or not"""
37
- assert fetcher.fetch(self.basic_url, network_idle=True).status == 200
38
-
39
- def test_blocking_resources(self, fetcher):
40
- """Test if blocking resources make page does not finish loading or not"""
41
- assert fetcher.fetch(self.basic_url, disable_resources=True).status == 200
42
-
43
- def test_waiting_selector(self, fetcher):
44
- """Test if waiting for a selector make page does not finish loading or not"""
45
- assert fetcher.fetch(self.html_url, wait_selector='h1').status == 200
46
- assert fetcher.fetch(self.html_url, wait_selector='h1', wait_selector_state='visible').status == 200
47
-
48
- def test_cookies_loading(self, fetcher):
49
- """Test if cookies are set after the request"""
50
- assert fetcher.fetch(self.cookies_url).cookies == {'test': 'value'}
51
-
52
- def test_automation(self, fetcher):
53
- """Test if automation break the code or not"""
54
-
55
- def scroll_page(page):
56
- page.mouse.wheel(10, 0)
57
- page.mouse.move(100, 400)
58
- page.mouse.up()
59
- return page
60
-
61
- assert fetcher.fetch(self.html_url, page_action=scroll_page).status == 200
62
-
63
- @pytest.mark.parametrize("kwargs", [
64
- {"disable_webgl": True, "hide_canvas": False},
65
- {"disable_webgl": False, "hide_canvas": True},
66
- # {"stealth": True}, # causes issues with Github Actions
67
- {"useragent": 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0'},
68
- {"extra_headers": {'ayo': ''}}
69
- ])
70
- def test_properties(self, fetcher, kwargs):
71
- """Test if different arguments breaks the code or not"""
72
- response = fetcher.fetch(self.html_url, **kwargs)
73
- assert response.status == 200
74
-
75
- def test_cdp_url_invalid(self, fetcher):
76
- """Test if invalid CDP URLs raise appropriate exceptions"""
77
- with pytest.raises(ValueError):
78
- fetcher.fetch(self.html_url, cdp_url='blahblah')
79
-
80
- with pytest.raises(ValueError):
81
- fetcher.fetch(self.html_url, cdp_url='blahblah', nstbrowser_mode=True)
82
-
83
- with pytest.raises(Exception):
84
- fetcher.fetch(self.html_url, cdp_url='ws://blahblah')
85
-
86
- def test_infinite_timeout(self, fetcher, ):
87
- """Test if infinite timeout breaks the code or not"""
88
- response = fetcher.fetch(self.delayed_url, timeout=None)
89
- assert response.status == 200
@@ -1,97 +0,0 @@
1
- import pytest
2
-
3
- from scrapling.engines.toolbelt.custom import ResponseEncoding, StatusText
4
-
5
-
6
- @pytest.fixture
7
- def content_type_map():
8
- return {
9
- # A map generated by ChatGPT for most possible `content_type` values and the expected outcome
10
- 'text/html; charset=UTF-8': 'UTF-8',
11
- 'text/html; charset=ISO-8859-1': 'ISO-8859-1',
12
- 'text/html': 'ISO-8859-1',
13
- 'application/json; charset=UTF-8': 'UTF-8',
14
- 'application/json': 'utf-8',
15
- 'text/json': 'utf-8',
16
- 'application/javascript; charset=UTF-8': 'UTF-8',
17
- 'application/javascript': 'utf-8',
18
- 'text/plain; charset=UTF-8': 'UTF-8',
19
- 'text/plain; charset=ISO-8859-1': 'ISO-8859-1',
20
- 'text/plain': 'ISO-8859-1',
21
- 'application/xhtml+xml; charset=UTF-8': 'UTF-8',
22
- 'application/xhtml+xml': 'utf-8',
23
- 'text/html; charset=windows-1252': 'windows-1252',
24
- 'application/json; charset=windows-1252': 'windows-1252',
25
- 'text/plain; charset=windows-1252': 'windows-1252',
26
- 'text/html; charset="UTF-8"': 'UTF-8',
27
- 'text/html; charset="ISO-8859-1"': 'ISO-8859-1',
28
- 'text/html; charset="windows-1252"': 'windows-1252',
29
- 'application/json; charset="UTF-8"': 'UTF-8',
30
- 'application/json; charset="ISO-8859-1"': 'ISO-8859-1',
31
- 'application/json; charset="windows-1252"': 'windows-1252',
32
- 'text/json; charset="UTF-8"': 'UTF-8',
33
- 'application/javascript; charset="UTF-8"': 'UTF-8',
34
- 'application/javascript; charset="ISO-8859-1"': 'ISO-8859-1',
35
- 'text/plain; charset="UTF-8"': 'UTF-8',
36
- 'text/plain; charset="ISO-8859-1"': 'ISO-8859-1',
37
- 'text/plain; charset="windows-1252"': 'windows-1252',
38
- 'application/xhtml+xml; charset="UTF-8"': 'UTF-8',
39
- 'application/xhtml+xml; charset="ISO-8859-1"': 'ISO-8859-1',
40
- 'application/xhtml+xml; charset="windows-1252"': 'windows-1252',
41
- 'text/html; charset="US-ASCII"': 'US-ASCII',
42
- 'application/json; charset="US-ASCII"': 'US-ASCII',
43
- 'text/plain; charset="US-ASCII"': 'US-ASCII',
44
- 'text/html; charset="Shift_JIS"': 'Shift_JIS',
45
- 'application/json; charset="Shift_JIS"': 'Shift_JIS',
46
- 'text/plain; charset="Shift_JIS"': 'Shift_JIS',
47
- 'application/xml; charset="UTF-8"': 'UTF-8',
48
- 'application/xml; charset="ISO-8859-1"': 'ISO-8859-1',
49
- 'application/xml': 'utf-8',
50
- 'text/xml; charset="UTF-8"': 'UTF-8',
51
- 'text/xml; charset="ISO-8859-1"': 'ISO-8859-1',
52
- 'text/xml': 'utf-8'
53
- }
54
-
55
-
56
- @pytest.fixture
57
- def status_map():
58
- return {
59
- 100: "Continue", 101: "Switching Protocols", 102: "Processing", 103: "Early Hints",
60
- 200: "OK", 201: "Created", 202: "Accepted", 203: "Non-Authoritative Information",
61
- 204: "No Content", 205: "Reset Content", 206: "Partial Content", 207: "Multi-Status",
62
- 208: "Already Reported", 226: "IM Used", 300: "Multiple Choices",
63
- 301: "Moved Permanently", 302: "Found", 303: "See Other", 304: "Not Modified",
64
- 305: "Use Proxy", 307: "Temporary Redirect", 308: "Permanent Redirect",
65
- 400: "Bad Request", 401: "Unauthorized", 402: "Payment Required", 403: "Forbidden",
66
- 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
67
- 407: "Proxy Authentication Required", 408: "Request Timeout", 409: "Conflict",
68
- 410: "Gone", 411: "Length Required", 412: "Precondition Failed",
69
- 413: "Payload Too Large", 414: "URI Too Long", 415: "Unsupported Media Type",
70
- 416: "Range Not Satisfiable", 417: "Expectation Failed", 418: "I'm a teapot",
71
- 421: "Misdirected Request", 422: "Unprocessable Entity", 423: "Locked",
72
- 424: "Failed Dependency", 425: "Too Early", 426: "Upgrade Required",
73
- 428: "Precondition Required", 429: "Too Many Requests",
74
- 431: "Request Header Fields Too Large", 451: "Unavailable For Legal Reasons",
75
- 500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway",
76
- 503: "Service Unavailable", 504: "Gateway Timeout",
77
- 505: "HTTP Version Not Supported", 506: "Variant Also Negotiates",
78
- 507: "Insufficient Storage", 508: "Loop Detected", 510: "Not Extended",
79
- 511: "Network Authentication Required"
80
- }
81
-
82
-
83
- def test_parsing_content_type(content_type_map):
84
- """Test if parsing different types of content-type returns the expected result"""
85
- for header_value, expected_encoding in content_type_map.items():
86
- assert ResponseEncoding.get_value(header_value) == expected_encoding
87
-
88
-
89
- def test_parsing_response_status(status_map):
90
- """Test if using different http responses' status codes returns the expected result"""
91
- for status_code, expected_status_text in status_map.items():
92
- assert StatusText.get(status_code) == expected_status_text
93
-
94
-
95
- def test_unknown_status_code():
96
- """Test handling of an unknown status code"""
97
- assert StatusText.get(1000) == "Unknown Status Code"
tests/parser/__init__.py DELETED
File without changes
@@ -1,111 +0,0 @@
1
- import asyncio
2
-
3
- import pytest
4
-
5
- from scrapling import Adaptor
6
-
7
-
8
- class TestParserAutoMatch:
9
- def test_element_relocation(self):
10
- """Test relocating element after structure change"""
11
- original_html = '''
12
- <div class="container">
13
- <section class="products">
14
- <article class="product" id="p1">
15
- <h3>Product 1</h3>
16
- <p class="description">Description 1</p>
17
- </article>
18
- <article class="product" id="p2">
19
- <h3>Product 2</h3>
20
- <p class="description">Description 2</p>
21
- </article>
22
- </section>
23
- </div>
24
- '''
25
- changed_html = '''
26
- <div class="new-container">
27
- <div class="product-wrapper">
28
- <section class="products">
29
- <article class="product new-class" data-id="p1">
30
- <div class="product-info">
31
- <h3>Product 1</h3>
32
- <p class="new-description">Description 1</p>
33
- </div>
34
- </article>
35
- <article class="product new-class" data-id="p2">
36
- <div class="product-info">
37
- <h3>Product 2</h3>
38
- <p class="new-description">Description 2</p>
39
- </div>
40
- </article>
41
- </section>
42
- </div>
43
- </div>
44
- '''
45
-
46
- old_page = Adaptor(original_html, url='example.com', auto_match=True)
47
- new_page = Adaptor(changed_html, url='example.com', auto_match=True)
48
-
49
- # 'p1' was used as ID and now it's not and all the path elements have changes
50
- # Also at the same time testing auto-match vs combined selectors
51
- _ = old_page.css('#p1, #p2', auto_save=True)[0]
52
- relocated = new_page.css('#p1', auto_match=True)
53
-
54
- assert relocated is not None
55
- assert relocated[0].attrib['data-id'] == 'p1'
56
- assert relocated[0].has_class('new-class')
57
- assert relocated[0].css('.new-description')[0].text == 'Description 1'
58
-
59
- @pytest.mark.asyncio
60
- async def test_element_relocation_async(self):
61
- """Test relocating element after structure change in async mode"""
62
- original_html = '''
63
- <div class="container">
64
- <section class="products">
65
- <article class="product" id="p1">
66
- <h3>Product 1</h3>
67
- <p class="description">Description 1</p>
68
- </article>
69
- <article class="product" id="p2">
70
- <h3>Product 2</h3>
71
- <p class="description">Description 2</p>
72
- </article>
73
- </section>
74
- </div>
75
- '''
76
- changed_html = '''
77
- <div class="new-container">
78
- <div class="product-wrapper">
79
- <section class="products">
80
- <article class="product new-class" data-id="p1">
81
- <div class="product-info">
82
- <h3>Product 1</h3>
83
- <p class="new-description">Description 1</p>
84
- </div>
85
- </article>
86
- <article class="product new-class" data-id="p2">
87
- <div class="product-info">
88
- <h3>Product 2</h3>
89
- <p class="new-description">Description 2</p>
90
- </div>
91
- </article>
92
- </section>
93
- </div>
94
- </div>
95
- '''
96
-
97
- # Simulate async operation
98
- await asyncio.sleep(0.1) # Minimal async operation
99
-
100
- old_page = Adaptor(original_html, url='example.com', auto_match=True)
101
- new_page = Adaptor(changed_html, url='example.com', auto_match=True)
102
-
103
- # 'p1' was used as ID and now it's not and all the path elements have changes
104
- # Also at the same time testing auto-match vs combined selectors
105
- _ = old_page.css('#p1, #p2', auto_save=True)[0]
106
- relocated = new_page.css('#p1', auto_match=True)
107
-
108
- assert relocated is not None
109
- assert relocated[0].attrib['data-id'] == 'p1'
110
- assert relocated[0].has_class('new-class')
111
- assert relocated[0].css('.new-description')[0].text == 'Description 1'