requestium 0.5.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.
requestium/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from selenium.common import exceptions # noqa: F401
2
+ from selenium.webdriver.common.by import By # noqa: F401
3
+ from selenium.webdriver.common.keys import Keys # noqa: F401
4
+ from selenium.webdriver.support.ui import Select # noqa: F401
5
+
6
+ from .requestium import Session # noqa: F401
@@ -0,0 +1,6 @@
1
+ from .requestium_mixin import ( # noqa: F401
2
+ DriverMixin,
3
+ _ensure_click,
4
+ )
5
+ from .requestium_response import RequestiumResponse # noqa: F401
6
+ from .requestium_session import Session # noqa: F401
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import time
5
+ import warnings
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import tldextract
9
+ from parsel.selector import Selector, SelectorList
10
+ from selenium.common.exceptions import NoSuchWindowException, WebDriverException
11
+ from selenium.webdriver.common.by import By, ByType
12
+ from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver
13
+ from selenium.webdriver.support import expected_conditions
14
+ from selenium.webdriver.support.ui import WebDriverWait
15
+
16
+ if TYPE_CHECKING:
17
+ from selenium.webdriver.remote.webelement import WebElement
18
+
19
+
20
+ DEFAULT_TIMEOUT: float = 0.5
21
+
22
+
23
+ def _ensure_click(self: WebElement) -> None:
24
+ """
25
+ Ensure a click gets made, because Selenium can be a bit buggy about clicks.
26
+
27
+ This method gets added to the selenium element returned in '__ensure_element_by_xpath'.
28
+ We should probably add it to more selenium methods, such as all the 'find**' methods though.
29
+
30
+ I wrote this method out of frustration with chromedriver and its problems with clicking
31
+ items that need to be scrolled to in order to be clickable. In '__ensure_element_by_xpath' we
32
+ scroll to the item before returning it, but chrome has some problems if it doesn't get some
33
+ time to scroll to the item. This method ensures chromes gets enough time to scroll to the item
34
+ before clicking it. I tried SEVERAL more 'correct' methods to get around this, but none of them
35
+ worked 100% of the time (waiting for the element to be 'clickable' does not work).
36
+ """
37
+ # We ensure the element is scrolled into the middle of the viewport to ensure that
38
+ # it is clickable. There are two main ways an element may not be clickable:
39
+ # - It is outside of the viewport
40
+ # - It is under a banner or toolbar
41
+ # This script solves both cases
42
+ script = (
43
+ "var viewPortHeight = Math.max("
44
+ "document.documentElement.clientHeight, window.innerHeight || 0);"
45
+ "var elementTop = arguments[0].getBoundingClientRect().top;"
46
+ "window.scrollBy(0, elementTop-(viewPortHeight/2));"
47
+ )
48
+ self.parent.execute_script(script, self) # parent = the webdriver
49
+
50
+ exception_message = ""
51
+ for _ in range(10):
52
+ try:
53
+ self.click()
54
+ return
55
+ except WebDriverException as e:
56
+ exception_message = str(e)
57
+ time.sleep(0.2)
58
+ msg = f"Couldn't click item after trying 10 times, got error message: \n{exception_message}"
59
+ raise WebDriverException(msg)
60
+
61
+
62
+ class DriverMixin(RemoteWebDriver):
63
+ """Provides helper methods to our driver classes."""
64
+
65
+ def __init__(self, *args, **kwargs) -> None:
66
+ self.default_timeout = kwargs.pop("default_timeout", DEFAULT_TIMEOUT)
67
+ super().__init__(*args, **kwargs)
68
+
69
+ def try_add_cookie(self, cookie: dict[str, Any]) -> bool:
70
+ """
71
+ Attempt to add the cookie.
72
+
73
+ Suppress any errors, and simply detect success or failure.
74
+ """
75
+ try:
76
+ self.add_cookie(cookie)
77
+ except WebDriverException as e:
78
+ if e.msg and not e.msg.__contains__("Couldn't add the following cookie to the webdriver"):
79
+ raise WebDriverException from e
80
+ return self.is_cookie_in_driver(cookie)
81
+
82
+ def ensure_add_cookie(self, cookie: dict[str, Any], override_domain: str | None = None) -> None:
83
+ """
84
+ Add a cookie to the driver and check to ensure it has been added.
85
+
86
+ Selenium needs the driver to be currently at the domain of the cookie
87
+ before allowing you to add it, so we need to get through this limitation.
88
+
89
+ The cookie parameter is a dict which must contain the keys (name, value, domain) and
90
+ may contain the keys (path, expiry).
91
+
92
+ We first check that we aren't currently in the cookie's domain, if we aren't, we GET
93
+ the cookie's domain and then add the cookies to the driver.
94
+
95
+ We can override the cookie's domain using 'override_domain'. The use for this
96
+ parameter is that sometimes GETting the cookie's domain redirects you to a different
97
+ sub domain, and therefore adding the cookie fails. So sometimes the user may
98
+ need to override the cookie's domain to a less strict one, Eg.: 'site.com' instead of
99
+ 'home.site.com', in this way even if the site redirects us to a subdomain, the cookie will
100
+ stick. If you set the domain to '', the cookie gets added with whatever domain the browser
101
+ is currently at (at least in chrome it does), so this ensures the cookie gets added.
102
+
103
+ It also retries adding the cookie with a more permissive domain if it fails in the first
104
+ try, and raises an exception if that fails. The standard selenium behaviour in this case
105
+ was to not do anything, which was very hard to debug.
106
+ """
107
+ if override_domain:
108
+ cookie["domain"] = override_domain
109
+
110
+ cookie_domain = cookie["domain"] if cookie["domain"][0] != "." else cookie["domain"][1:]
111
+ try:
112
+ browser_domain = tldextract.extract(self.current_url).fqdn
113
+ except (AttributeError, NoSuchWindowException):
114
+ browser_domain = ""
115
+ if cookie_domain not in browser_domain:
116
+ # TODO @joaqo: Check if hardcoding 'http' causes trouble.
117
+ # https://github.com/tryolabs/requestium/issues/97
118
+ # Consider using a new proxy for this next request to not cause an anomalous
119
+ # request. This way their server sees our ip address as continuously having the
120
+ # same cookies and not have a request mid-session with no cookies
121
+ self.get("http://" + cookie_domain)
122
+
123
+ cookie_added = self.try_add_cookie(cookie)
124
+
125
+ # If we fail adding the cookie, retry with a more permissive domain
126
+ if not cookie_added:
127
+ cookie["domain"] = tldextract.extract(cookie["domain"]).top_domain_under_public_suffix
128
+ cookie_added = self.try_add_cookie(cookie)
129
+ if not cookie_added:
130
+ msg = f"Couldn't add the following cookie to the webdriver: {cookie}"
131
+ raise WebDriverException(msg)
132
+
133
+ def is_cookie_in_driver(self, cookie: dict[str, Any]) -> bool:
134
+ """
135
+ We check that the cookie is correctly added to the driver.
136
+
137
+ We only compare name, value and domain, as the rest can produce false negatives.
138
+ We are a bit lenient when comparing domains.
139
+ """
140
+ for driver_cookie in self.get_cookies():
141
+ name_matches = cookie["name"] == driver_cookie["name"]
142
+ value_matches = cookie["value"] == driver_cookie["value"]
143
+ domain_matches = driver_cookie["domain"] in (cookie["domain"], "." + cookie["domain"])
144
+ if name_matches and value_matches and domain_matches:
145
+ return True
146
+ return False
147
+
148
+ def ensure_element_by_id(self, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
149
+ return self.ensure_element(By.ID, selector, state, timeout)
150
+
151
+ def ensure_element_by_name(self, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
152
+ return self.ensure_element(By.NAME, selector, state, timeout)
153
+
154
+ def ensure_element_by_xpath(self, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
155
+ return self.ensure_element(By.XPATH, selector, state, timeout)
156
+
157
+ def ensure_element_by_link_text(self, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
158
+ return self.ensure_element(By.LINK_TEXT, selector, state, timeout)
159
+
160
+ def ensure_element_by_partial_link_text(self, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
161
+ return self.ensure_element(By.PARTIAL_LINK_TEXT, selector, state, timeout)
162
+
163
+ def ensure_element_by_tag_name(self, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
164
+ return self.ensure_element(By.TAG_NAME, selector, state, timeout)
165
+
166
+ def ensure_element_by_class_name(self, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
167
+ return self.ensure_element(By.CLASS_NAME, selector, state, timeout)
168
+
169
+ def ensure_element_by_css_selector(self, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
170
+ return self.ensure_element(By.CSS_SELECTOR, selector, state, timeout)
171
+
172
+ def ensure_element(self, locator: ByType | str, selector: str, state: str | None = "present", timeout: float | None = None) -> WebElement | None:
173
+ """
174
+ Wait until an element appears or disappears in the browser.
175
+
176
+ The webdriver runs in parallel with our scripts, so we must wait for it everytime it
177
+ runs javascript. Selenium automatically waits till a page loads when GETing it,
178
+ but it doesn't do this when it runs javascript and makes AJAX requests.
179
+ So we must explicitly wait in that case.
180
+
181
+ The 'locator' argument defines what strategy we use to search for the element.
182
+ It expects standard names from the By class in selenium.webdriver.common.by.
183
+ https://www.selenium.dev/selenium/docs/api/py/webdriver/selenium.webdriver.common.by.html
184
+
185
+ The 'state' argument allows us to chose between waiting for the element to be visible,
186
+ clickable, present, or invisible. Presence is more inclusive, but sometimes we want to
187
+ know if the element is visible. Careful, its not always intuitive what Selenium considers
188
+ to be a visible element. We can also wait for it to be clickable, although this method
189
+ is a bit buggy in selenium, an element can be 'clickable' according to selenium and
190
+ still fail when we try to click it.
191
+
192
+ More info at: http://selenium-python.readthedocs.io/waits.html
193
+ """
194
+ locators_compatibility = {
195
+ "link_text": By.LINK_TEXT,
196
+ "partial_link_text": By.PARTIAL_LINK_TEXT,
197
+ "tag_name": By.TAG_NAME,
198
+ "class_name": By.CLASS_NAME,
199
+ "css_selector": By.CSS_SELECTOR,
200
+ }
201
+ if locator in locators_compatibility:
202
+ warnings.warn(
203
+ """
204
+ Support for locator strategy names with underscores is deprecated.
205
+ Use strategies from Selenium's By class (importable from selenium.webdriver.common.by).
206
+ """,
207
+ DeprecationWarning,
208
+ stacklevel=2,
209
+ )
210
+ locator = locators_compatibility[locator]
211
+
212
+ if not timeout:
213
+ timeout = self.default_timeout or DEFAULT_TIMEOUT
214
+
215
+ if state == "visible":
216
+ element = WebDriverWait(self, timeout).until(expected_conditions.visibility_of_element_located((locator, selector)))
217
+ elif state == "clickable":
218
+ element = WebDriverWait(self, timeout).until(expected_conditions.element_to_be_clickable((locator, selector)))
219
+ elif state == "present":
220
+ element = WebDriverWait(self, timeout).until(expected_conditions.presence_of_element_located((locator, selector)))
221
+ elif state == "invisible":
222
+ WebDriverWait(self, timeout).until(expected_conditions.invisibility_of_element_located((locator, selector)))
223
+ element = None
224
+ else:
225
+ msg = f"The 'state' argument must be 'visible', 'clickable', 'present' or 'invisible', not '{state}'"
226
+ raise ValueError(msg)
227
+
228
+ # We add this method to our element to provide a more robust click. Chromedriver
229
+ # sometimes needs some time before it can click an item, specially if it needs to
230
+ # scroll into it first. This method ensures clicks don't fail because of this.
231
+ if element:
232
+ element.ensure_click = functools.partial(_ensure_click, element) # type: ignore[attr-defined]
233
+ return element
234
+
235
+ @property
236
+ def selector(self) -> Selector:
237
+ """
238
+ Returns the current state of the browser in a Selector.
239
+
240
+ We re-parse the site on each xpath, css, re call because we are running a web browser
241
+ and the site may change between calls
242
+ """
243
+ return Selector(text=self.page_source)
244
+
245
+ def xpath(self, *args, **kwargs) -> SelectorList[Selector]:
246
+ return self.selector.xpath(*args, **kwargs)
247
+
248
+ def css(self, *args, **kwargs) -> SelectorList[Selector]:
249
+ return self.selector.css(*args, **kwargs)
250
+
251
+ def re(self, *args, **kwargs) -> list[str]:
252
+ return self.selector.re(*args, **kwargs)
253
+
254
+ def re_first(self, *args, **kwargs) -> str | None:
255
+ return self.selector.re_first(*args, **kwargs)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import requests
4
+ from parsel.selector import Selector, SelectorList
5
+ from requests import Response
6
+
7
+
8
+ class RequestiumResponse(requests.Response):
9
+ """Adds xpath, css, and regex methods to a normal requests response object."""
10
+
11
+ def __init__(self, response: Response) -> None:
12
+ super().__init__()
13
+ self.__class__ = type(response.__class__.__name__, (self.__class__, response.__class__), response.__dict__)
14
+
15
+ @property
16
+ def selector(self) -> Selector:
17
+ """
18
+ Returns the response text in a Selector.
19
+
20
+ We re-parse the text on each xpath, css, re call in case the encoding has changed.
21
+ """
22
+ return Selector(text=self.text)
23
+
24
+ def xpath(self, *args, **kwargs) -> SelectorList[Selector]:
25
+ return self.selector.xpath(*args, **kwargs)
26
+
27
+ def css(self, *args, **kwargs) -> SelectorList[Selector]:
28
+ return self.selector.css(*args, **kwargs)
29
+
30
+ def re(self, *args, **kwargs) -> list[str]:
31
+ return self.selector.re(*args, **kwargs)
32
+
33
+ def re_first(self, *args, **kwargs) -> str | None:
34
+ return self.selector.re_first(*args, **kwargs)
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import types
5
+ from typing import Any
6
+
7
+ import requests
8
+ import tldextract
9
+ from selenium import webdriver
10
+ from selenium.common import InvalidCookieDomainException
11
+ from selenium.webdriver import ChromeService
12
+
13
+ from .requestium_mixin import DriverMixin
14
+ from .requestium_response import RequestiumResponse
15
+
16
+ RequestiumChrome = type("RequestiumChrome", (DriverMixin, webdriver.Chrome), {})
17
+
18
+
19
+ class Session(requests.Session):
20
+ """
21
+ Class that adds a Selenium Webdriver and helper methods to a Requests Session.
22
+
23
+ This session class is a normal Requests Session that has the ability to switch back
24
+ and forth between this session and a webdriver, allowing us to run js when needed.
25
+
26
+ Cookie transfer is done with the 'transfer' methods.
27
+
28
+ Header and proxy transfer is done only one time when the driver process starts.
29
+
30
+ Some useful helper methods and object wrappings have been added.
31
+ """
32
+
33
+ def _start_chrome_browser(self, headless: bool | None = False): # noqa C901
34
+ # TODO @joaqo: Transfer of proxies and headers.
35
+ # https://github.com/tryolabs/requestium/issues/96
36
+ # Not currently supported by chromedriver. Choosing not to use plug-ins
37
+ # for this as I don't want to worry about the extra dependencies and
38
+ # plug-ins don't work in headless mode. :-(
39
+ chrome_options = webdriver.ChromeOptions()
40
+
41
+ if headless:
42
+ chrome_options.add_argument("headless=new")
43
+
44
+ if "binary_location" in self.webdriver_options:
45
+ chrome_options.binary_location = self.webdriver_options["binary_location"]
46
+
47
+ if "arguments" in self.webdriver_options:
48
+ if isinstance(self.webdriver_options["arguments"], list):
49
+ for arg in self.webdriver_options["arguments"]:
50
+ chrome_options.add_argument(arg)
51
+ else:
52
+ msg = f"'arguments' option must be a list, but got {type(self.webdriver_options['arguments']).__name__}"
53
+ raise TypeError(msg)
54
+
55
+ if "extensions" in self.webdriver_options and isinstance(self.webdriver_options["extensions"], list):
56
+ for arg in self.webdriver_options["extensions"]:
57
+ chrome_options.add_extension(arg)
58
+
59
+ if "prefs" in self.webdriver_options:
60
+ prefs = self.webdriver_options["prefs"]
61
+ chrome_options.add_experimental_option("prefs", prefs)
62
+
63
+ experimental_options = self.webdriver_options.get("experimental_options")
64
+ if isinstance(experimental_options, dict):
65
+ for name, value in experimental_options.items():
66
+ chrome_options.add_experimental_option(name, value)
67
+
68
+ # Selenium updated webdriver.Chrome's arg and kwargs, to accept options, service, keep_alive
69
+ # since ChromeService is the only object where webdriver_path is mapped to executable_path, it must be
70
+ # initialized and passed in as a kwarg to RequestiumChrome so it can be passed in as a kwarg
71
+ # when passed into webdriver.Chrome in super(DriverMixin, self).__init__(*args, **kwargs)
72
+ service = ChromeService(executable_path=self.webdriver_path)
73
+ return RequestiumChrome(service=service, options=chrome_options, default_timeout=self.default_timeout)
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ webdriver_path: str | None = None,
79
+ headless: bool | None = None,
80
+ default_timeout: float = 5,
81
+ webdriver_options: dict[str, Any] | None = None,
82
+ driver: DriverMixin | None = None,
83
+ ) -> None:
84
+ super().__init__()
85
+
86
+ if webdriver_options is None:
87
+ webdriver_options = {}
88
+
89
+ self.webdriver_path = webdriver_path
90
+ self.default_timeout = default_timeout
91
+ self.webdriver_options = webdriver_options
92
+ self._driver = driver
93
+ self._last_requests_url: str | None = None
94
+
95
+ if not self._driver:
96
+ self._driver_initializer = functools.partial(self._start_chrome_browser, headless=headless)
97
+ else:
98
+ for name in DriverMixin.__dict__:
99
+ name_private = name.startswith("__") and name.endswith("__")
100
+ name_function = isinstance(DriverMixin.__dict__[name], types.FunctionType)
101
+ name_in_driver = name in dir(self._driver)
102
+ if name_private or not name_function or name_in_driver:
103
+ continue
104
+ self._driver.__dict__[name] = DriverMixin.__dict__[name].__get__(self._driver)
105
+ self._driver.default_timeout = self.default_timeout
106
+
107
+ @property
108
+ def driver(self) -> DriverMixin:
109
+ if self._driver is None:
110
+ self._driver = self._driver_initializer()
111
+ return self._driver
112
+
113
+ def transfer_session_cookies_to_driver(self, domain: str | None = None) -> None:
114
+ """
115
+ Copy the Session's cookies into the webdriver.
116
+
117
+ Using the 'domain' parameter we choose the cookies we wish to transfer, we only
118
+ transfer the cookies which belong to that domain. The domain defaults to our last visited
119
+ site if not provided.
120
+ """
121
+ if not domain and self._last_requests_url:
122
+ domain = tldextract.extract(self._last_requests_url).top_domain_under_public_suffix
123
+
124
+ if not domain:
125
+ msg = "Trying to transfer cookies to selenium without specifying a domain and without having visited any page in the current session"
126
+ raise InvalidCookieDomainException(msg)
127
+
128
+ # Transfer cookies
129
+ for c in [c for c in self.cookies if domain in c.domain]:
130
+ cookie = {"name": c.name, "value": c.value, "path": c.path, "expiry": c.expires, "domain": c.domain}
131
+
132
+ self.driver.ensure_add_cookie({k: v for k, v in cookie.items() if v is not None})
133
+
134
+ def transfer_driver_cookies_to_session(self, *, copy_user_agent: bool | None = True) -> None:
135
+ if copy_user_agent:
136
+ self.copy_user_agent_from_driver()
137
+
138
+ for cookie in self.driver.get_cookies():
139
+ self.cookies.set(cookie["name"], cookie["value"], domain=cookie["domain"])
140
+
141
+ def get(self, *args, **kwargs) -> RequestiumResponse:
142
+ resp = super().get(*args, **kwargs)
143
+ self._last_requests_url = resp.url
144
+ return RequestiumResponse(resp)
145
+
146
+ def post(self, *args, **kwargs) -> RequestiumResponse:
147
+ resp = super().post(*args, **kwargs)
148
+ self._last_requests_url = resp.url
149
+ return RequestiumResponse(resp)
150
+
151
+ def put(self, *args, **kwargs) -> RequestiumResponse:
152
+ resp = super().put(*args, **kwargs)
153
+ self._last_requests_url = resp.url
154
+ return RequestiumResponse(resp)
155
+
156
+ def copy_user_agent_from_driver(self) -> None:
157
+ """
158
+ Update requests' session user-agent with the driver's user agent.
159
+
160
+ This method will start the browser process if its not already running.
161
+ """
162
+ selenium_user_agent = self.driver.execute_script("return navigator.userAgent;")
163
+ self.headers.update({"user-agent": selenium_user_agent})
@@ -0,0 +1,376 @@
1
+ Metadata-Version: 2.4
2
+ Name: requestium
3
+ Version: 0.5.1
4
+ Author-email: Joaquin Alori <joaquin@tryolabs.com>
5
+ Maintainer-email: Judson Neer <jkudson.neer@gmail.com>
6
+ License: BSD 3-Clause License
7
+
8
+ Copyright (c) 2017, Tryolabs
9
+ All rights reserved.
10
+
11
+ Redistribution and use in source and binary forms, with or without
12
+ modification, are permitted provided that the following conditions are met:
13
+
14
+ * Redistributions of source code must retain the above copyright notice, this
15
+ list of conditions and the following disclaimer.
16
+
17
+ * Redistributions in binary form must reproduce the above copyright notice,
18
+ this list of conditions and the following disclaimer in the documentation
19
+ and/or other materials provided with the distribution.
20
+
21
+ * Neither the name of the copyright holder nor the names of its
22
+ contributors may be used to endorse or promote products derived from
23
+ this software without specific prior written permission.
24
+
25
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
26
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
27
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
28
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
29
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
30
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
31
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
32
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
33
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
34
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
35
+
36
+ Project-URL: source, https://github.com/tryolabs/requestium
37
+ Project-URL: download, https://pypi.org/project/requestium/#files
38
+ Project-URL: issues, https://github.com/tryolabs/requestium/issues
39
+ Classifier: Development Status :: 4 - Beta
40
+ Classifier: Intended Audience :: Developers
41
+ Classifier: License :: OSI Approved :: BSD License
42
+ Classifier: Programming Language :: Python :: 3.10
43
+ Classifier: Programming Language :: Python :: 3.11
44
+ Classifier: Programming Language :: Python :: 3.12
45
+ Classifier: Programming Language :: Python :: 3.13
46
+ Classifier: Programming Language :: Python :: 3.14
47
+ Classifier: Natural Language :: English
48
+ Classifier: Operating System :: MacOS
49
+ Classifier: Operating System :: Microsoft :: Windows
50
+ Classifier: Operating System :: POSIX
51
+ Classifier: Operating System :: OS Independent
52
+ Classifier: Environment :: Web Environment
53
+ Classifier: Programming Language :: Python :: Implementation :: CPython
54
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
55
+ Classifier: Topic :: Software Development :: Testing
56
+ Requires-Python: >=3.10
57
+ Description-Content-Type: text/markdown
58
+ License-File: LICENSE
59
+ Requires-Dist: parsel>=1.7
60
+ Requires-Dist: requests>=2.27
61
+ Requires-Dist: selenium>=4.32
62
+ Requires-Dist: tldextract>=5.3
63
+ Provides-Extra: dev
64
+ Requires-Dist: bandit[sarif]==1.9.2; extra == "dev"
65
+ Requires-Dist: mypy==1.19.1; extra == "dev"
66
+ Requires-Dist: pre-commit~=4.5.0; extra == "dev"
67
+ Requires-Dist: pytest-cov~=7.0.0; extra == "dev"
68
+ Requires-Dist: pytest-xdist~=3.8.0; extra == "dev"
69
+ Requires-Dist: pytest~=9.0.0; extra == "dev"
70
+ Requires-Dist: ruff==0.14.11; extra == "dev"
71
+ Requires-Dist: ssort==0.16.0; extra == "dev"
72
+ Requires-Dist: types-requests==2.32.4.20260107; extra == "dev"
73
+ Dynamic: license-file
74
+
75
+ ![Requestium](https://user-images.githubusercontent.com/14966348/32966130-8bb15b00-cbb7-11e7-9faf-85963ec5bd82.png)
76
+ ========
77
+
78
+ [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
79
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/requestium)](https://pypi.org/project/requestium/)
80
+ [![PyPI](https://img.shields.io/pypi/v/requestium?color=blue)](https://pypi.org/project/requestium/)
81
+ [![Coverage](https://raw.githubusercontent.com/tryolabs/requestium/python-coverage-comment-action-data/badge.svg)](https://htmlpreview.github.io/?https://github.com/tryolabs/requestium/blob/python-coverage-comment-action-data/htmlcov/index.html)
82
+ [![Python checks](https://github.com/tryolabs/requestium/actions/workflows/python.yml/badge.svg)](https://github.com/tryolabs/requestium/actions/workflows/python.yml)
83
+
84
+ Requestium is a Python library that merges the power of [Requests](https://github.com/requests/requests), [Selenium](https://github.com/SeleniumHQ/selenium), and [Parsel](https://github.com/scrapy/parsel) into a single integrated tool for automatizing web actions.
85
+
86
+ The library was created for writing web automation scripts that are written using mostly Requests but that are able to seamlessly switch to Selenium for the JavaScript heavy parts of the website, while maintaining the session.
87
+
88
+ Requestium adds independent improvements to both Requests and Selenium, and every new feature is lazily evaluated, so its useful even if writing scripts that use only Requests or Selenium.
89
+
90
+ Read more about the motivation behind creating this library in this [blopost](https://tryolabs.com/blog/2017/11/22/requestium-integration-layer-requests-selenium-web-automation/).
91
+
92
+ ## Features
93
+ - Enables switching between a Requests' Session and a Selenium webdriver while maintaining the current web session.
94
+ - Integrates Parsel's parser into the library, making xpath, css, and regex much cleaner to write.
95
+ - Improves Selenium's handling of dynamically loading elements.
96
+ - Makes cookie handling more flexible in Selenium.
97
+ - Makes clicking elements in Selenium more reliable.
98
+ - Supports Chromedriver natively plus adding a custom webdriver.
99
+
100
+ ## Installation
101
+ ```bash
102
+ pip install requestium
103
+ ```
104
+
105
+ You should then download your preferred Selenium webdriver if you plan to use the Selenium part of Requestium, such as [Chromedriver](https://sites.google.com/a/chromium.org/chromedriver/).
106
+
107
+ ## Usage
108
+ First create a session as you would do on Requests, and optionally add arguments for the web-driver if you plan to use one.
109
+ ```python
110
+ from requestium import Session, Keys
111
+
112
+ options = {'arguments': ['headless']}
113
+ s = Session(webdriver_path='./chromedriver', default_timeout=15, webdriver_options=options)
114
+ ```
115
+
116
+ Since headless mode is common, there's a shortcut for it by specifying `headless=True`.
117
+
118
+ ```python
119
+ from requestium import Session, Keys
120
+
121
+ s = Session(webdriver_path='./chromedriver' headless=True)
122
+ ```
123
+
124
+ You can also create a Selenium webdriver outside Requestium and have it use that instead:
125
+
126
+ ```python
127
+ from selenium import webdriver
128
+ from requestium import Session, Keys
129
+
130
+ firefox_driver = webdriver.Firefox()
131
+
132
+ s = Session(driver=firefox_driver)
133
+ ```
134
+
135
+ You can also specify a 3rd party Chrome webdriver class and use it by specifying the `browser` argument as well. This will allow, for example, to use [Selenium-Wire](https://github.com/wkeeling/selenium-wire) to get XHR requests of a web page:
136
+
137
+ ```python
138
+ from seleniumwire import webdriver
139
+ from requestium import Session, Keys
140
+
141
+ seleniumwire_driver = webdriver.Chrome()
142
+
143
+ s = Session(webdriver_path='./chromedriver', driver=seleniumwire_driver)
144
+
145
+ ```
146
+
147
+ You don't need to parse the response, it is done automatically when calling xpath, css or re.
148
+ ```python
149
+ title = s.get('http://samplesite.com').xpath('//title/text()').extract_first(default='Default Title')
150
+ ```
151
+
152
+ Regex require less boilerplate when compared to Python's standard `re` module.
153
+ ```python
154
+ response = s.get('http://samplesite.com/sample_path')
155
+
156
+ # Extracts the first match
157
+ identifier = response.re_first(r'ID_\d\w\d', default='ID_1A1')
158
+
159
+ # Extracts all matches as a list
160
+ users = response.re(r'user_\d\d\d')
161
+ ```
162
+
163
+ The Session object is just a regular Requests's session object, so you can use all of its methods.
164
+ ```python
165
+ s.post('http://www.samplesite.com/sample', data={'field1': 'data1'})
166
+ s.proxies.update({'http': 'http://10.11.4.254:3128', 'https': 'https://10.11.4.252:3128'})
167
+ ```
168
+
169
+ And you can switch to using the Selenium webdriver to run any js code.
170
+ ```python
171
+ s.transfer_session_cookies_to_driver() # You can maintain the session if needed
172
+ s.driver.get('http://www.samplesite.com/sample/process')
173
+ ```
174
+
175
+ The driver object is a Selenium webdriver object, so you can use any of the normal selenium methods plus new methods added by Requestium.
176
+ ```python
177
+ s.driver.find_element("xpath", "//input[@class='user_name']").send_keys('James Bond', Keys.ENTER)
178
+
179
+ # New methods which wait for element to load instead of failing, useful for single page web apps
180
+ s.driver.ensure_element("xpath", "//div[@attribute='button']").click()
181
+ s.driver.ensure_element_by_xpath("//div[@attribute='button']").click()
182
+ ```
183
+
184
+ Requestium also adds xpath, css, and re methods to the Selenium driver object.
185
+ ```python
186
+ if s.driver.re(r'ID_\d\w\d some_pattern'):
187
+ print('Found it!')
188
+ ```
189
+
190
+ And finally you can switch back to using Requests.
191
+ ```python
192
+ s.transfer_driver_cookies_to_session()
193
+ s.post('http://www.samplesite.com/sample2', data={'key1': 'value1'})
194
+ ```
195
+
196
+ ## Selenium workarounds
197
+ Requestium adds several 'ensure' methods to the driver object, as Selenium is known to be very finicky about selecting elements and cookie handling.
198
+
199
+ ### Wait for element
200
+ The `ensure_element` and `ensure_element_by_` methods wait for the element to be loaded in the browser and returns it as soon as it loads. They're named after Selenium's `find_element` and `find_element_by_` methods (which immediately raise an exception if they can't find the element).
201
+
202
+ Requestium can wait for an element to be in any of the following states:
203
+ - present (default)
204
+ - clickable
205
+ - visible
206
+ - invisible (useful for things like waiting for *loading...* gifs to disappear)
207
+
208
+ These methods are very useful for single page web apps where the site is dynamically changing its elements. We usually end up completely replacing our `find_element` and `find_element_by_` calls with `ensure_element` and `ensure_element_by_` calls as they are more flexible.
209
+
210
+ Elements you get using these methods have the new `ensure_click` method which makes the click less prone to failure. This helps with getting through a lot of the problems with Selenium clicking.
211
+
212
+ ```python
213
+ s.driver.ensure_element("xpath", "//li[@class='b1']", state='clickable', timeout=5).ensure_click()
214
+
215
+ # === We also added these methods named in accordance to Selenium's api design ===
216
+ # ensure_element_by_id
217
+ # ensure_element_by_name
218
+ # ensure_element_by_xpath
219
+ # ensure_element_by_link_text
220
+ # ensure_element_by_partial_link_text
221
+ # ensure_element_by_tag_name
222
+ # ensure_element_by_class_name
223
+ # ensure_element_by_css_selector
224
+ ```
225
+
226
+ ### Add cookie
227
+ The `ensure_add_cookie` method makes adding cookies much more robust. Selenium needs the browser to be at the cookie's domain before being able to add the cookie, this method offers several workarounds for this. If the browser is not in the cookies domain, it GETs the domain before adding the cookie. It also allows you to override the domain before adding it, and avoid making this GET. The domain can be overridden to `''`, this sets the cookie's domain to whatever domain the driver is currently in.
228
+
229
+ If it can't add the cookie it tries to add it with a less restrictive domain (Eg.: home.site.com -> site.com) before failing.
230
+
231
+ ```python
232
+ cookie = {"domain": "www.site.com",
233
+ "secure": false,
234
+ "value": "sd2451dgd13",
235
+ "expiry": 1516824855.759154,
236
+ "path": "/",
237
+ "httpOnly": true,
238
+ "name": "sessionid"}
239
+ s.driver.ensure_add_cookie(cookie, override_domain='')
240
+ ```
241
+
242
+ ## Considerations
243
+ New features are lazily evaluated, meaning:
244
+ - The Selenium webdriver process is only started if you call the driver object. So if you don't need to use the webdriver, you could use the library with no overhead. Very useful if you just want to use the library for its integration with Parsel.
245
+ - Parsing of the responses is only done if you call the `xpath`, `css`, or `re` methods of the response. So again there is no overhead if you don't need to use this feature.
246
+
247
+ A byproduct of this is that the Selenium webdriver could be used just as a tool to ease in the development of regular Requests code: You can start writing your script using just the Requests' session, and at the last step of the script (the one you are currently working on) transfer the session to the Chrome webdriver. This way, a Chrome process starts in your machine, and acts as a real time "visor" for the last step of your code. You can see in what state your session is currently in, inspect it with Chrome's excellent inspect tools, and decide what's the next step your session object should take. Very useful to try code in an IPython interpreter and see how the site reacts in real time.
248
+
249
+ When `transfer_driver_cookies_to_session` is called, Requestium automatically updates your Requests session user-agent to match that of the browser used in Selenium. This doesn't happen when running Requests without having switched from a Selenium session first though. So if you just want to run Requests but want it to use your browser's user agent instead of the default one (which sites love to block), just run:
250
+ ```python
251
+ s.copy_user_agent_from_driver()
252
+ ```
253
+ Take into account that doing this will launch a browser process.
254
+
255
+ Note: The Selenium Chrome webdriver doesn't support automatic transfer of proxies from the Session to the Webdriver at the moment.
256
+
257
+ ## Comparison with Requests + Selenium + lxml
258
+ A silly working example of a script that runs on Reddit. We'll then show how it compares to using Requests + Selenium + lxml instead of Requestium.
259
+
260
+ ### Using Requestium
261
+ ```python
262
+ from requestium import Session, Keys
263
+
264
+ # If you want requestium to type your username in the browser for you, write it in here:
265
+ reddit_user_name = ''
266
+
267
+ s = Session('./chromedriver', default_timeout=15)
268
+ s.driver.get('http://reddit.com')
269
+ s.driver.find_element("xpath", "//a[@href='https://www.reddit.com/login']").click()
270
+
271
+ print('Waiting for elements to load...')
272
+ s.driver.ensure_element("class name", "desktop-onboarding-sign-up__form-toggler",
273
+ state='visible').click()
274
+
275
+ if reddit_user_name:
276
+ s.driver.ensure_element('id', 'user_login').send_keys(reddit_user_name)
277
+ s.driver.ensure_element('id', 'passwd_login').send_keys(Keys.BACKSPACE)
278
+ print('Please log-in in the chrome browser')
279
+
280
+ s.driver.ensure_element("class name", "desktop-onboarding__title", timeout=60, state='invisible')
281
+ print('Thanks!')
282
+
283
+ if not reddit_user_name:
284
+ reddit_user_name = s.driver.xpath("//span[@class='user']//text()").extract_first()
285
+
286
+ if reddit_user_name:
287
+ s.transfer_driver_cookies_to_session()
288
+ response = s.get("https://www.reddit.com/user/{}/".format(reddit_user_name))
289
+ cmnt_karma = response.xpath("//span[@class='karma comment-karma']//text()").extract_first()
290
+ reddit_golds_given = response.re_first(r"(\d+) gildings given out")
291
+ print("Comment karma: {}".format(cmnt_karma))
292
+ print("Reddit golds given: {}".format(reddit_golds_given))
293
+ else:
294
+ print("Couldn't get user name")
295
+ ```
296
+
297
+ ### Using Requests + Selenium + lxml
298
+
299
+ ```python
300
+ import re
301
+ from lxml import etree
302
+ from requests import Session
303
+ from selenium import webdriver
304
+ from selenium.common.exceptions import TimeoutException
305
+ from selenium.webdriver.common.keys import Keys
306
+ from selenium.webdriver.common.by import By
307
+ from selenium.webdriver.support.ui import WebDriverWait
308
+ from selenium.webdriver.support import expected_conditions as EC
309
+
310
+ # If you want requestium to type your username in the browser for you, write it in here:
311
+ reddit_user_name = ''
312
+
313
+ driver = webdriver.Chrome('./chromedriver')
314
+ driver.get('http://reddit.com')
315
+ driver.find_element("xpath", "//a[@href='https://www.reddit.com/login']").click()
316
+
317
+ print('Waiting for elements to load...')
318
+ WebDriverWait(driver, 15).until(
319
+ EC.visibility_of_element_located((By.CLASS_NAME, "desktop-onboarding-sign-up__form-toggler"))
320
+ ).click()
321
+
322
+ if reddit_user_name:
323
+ WebDriverWait(driver, 15).until(
324
+ EC.presence_of_element_located((By.ID, 'user_login'))
325
+ ).send_keys(reddit_user_name)
326
+ driver.find_element('id', 'passwd_login').send_keys(Keys.BACKSPACE)
327
+ print('Please log-in in the chrome browser')
328
+
329
+ try:
330
+ WebDriverWait(driver, 3).until(
331
+ EC.presence_of_element_located((By.CLASS_NAME, "desktop-onboarding__title"))
332
+ )
333
+ except TimeoutException:
334
+ pass
335
+ WebDriverWait(driver, 60).until(
336
+ EC.invisibility_of_element_located((By.CLASS_NAME, "desktop-onboarding__title"))
337
+ )
338
+ print('Thanks!')
339
+
340
+ if not reddit_user_name:
341
+ tree = etree.HTML(driver.page_source)
342
+ try:
343
+ reddit_user_name = tree.xpath("//span[@class='user']//text()")[0]
344
+ except IndexError:
345
+ reddit_user_name = None
346
+
347
+ if reddit_user_name:
348
+ s = Session()
349
+ # Reddit will think we are a bot if we have the wrong user agent
350
+ selenium_user_agent = driver.execute_script("return navigator.userAgent;")
351
+ s.headers.update({"user-agent": selenium_user_agent})
352
+ for cookie in driver.get_cookies():
353
+ s.cookies.set(cookie['name'], cookie['value'], domain=cookie['domain'])
354
+ response = s.get("https://www.reddit.com/user/{}/".format(reddit_user_name))
355
+ try:
356
+ cmnt_karma = etree.HTML(response.content).xpath(
357
+ "//span[@class='karma comment-karma']//text()")[0]
358
+ except IndexError:
359
+ cmnt_karma = None
360
+ match = re.search(r"(\d+) gildings given out", str(response.content))
361
+ if match:
362
+ reddit_golds_given = match.group(1)
363
+ else:
364
+ reddit_golds_given = None
365
+ print("Comment karma: {}".format(cmnt_karma))
366
+ print("Reddit golds given: {}".format(reddit_golds_given))
367
+ else:
368
+ print("Couldn't get user name")
369
+ ```
370
+
371
+ ## Similar Projects
372
+ This project intends to be a drop-in replacement of Requests' Session object, with added functionality. If your use case is a drop in replacement for a Selenium webdriver, but that also has some of Requests' functionality, [Selenium-Requests](https://github.com/cryzed/Selenium-Requests) does just that.
373
+
374
+
375
+ ## License
376
+ Copyright © 2018, [Tryolabs](https://tryolabs.com/). Released under the [BSD 3-Clause](https://github.com/tryolabs/requestium/blob/master/LICENSE).
@@ -0,0 +1,10 @@
1
+ requestium/__init__.py,sha256=BEjUrsq_EjA5osONG3zj3n_x2B2SgemDqY1XHYRkfsQ,283
2
+ requestium/requestium.py,sha256=nsSQEmVFIcRHz0YvoepB3deXy46B06eqrv339O4FLWs,204
3
+ requestium/requestium_mixin.py,sha256=6tdyRPX1qvP_BlZAaKLtTMKHGTFWRku0Ot459gFMwZM,12754
4
+ requestium/requestium_response.py,sha256=5nta1i3x2Ya4tZiYGPznr15Guc36j3p3q8B5p1bddc0,1154
5
+ requestium/requestium_session.py,sha256=Ia5PlswYZQXwwzRkqxqzz2IIHFX4RCSQ8dR3a6lozKU,7051
6
+ requestium-0.5.1.dist-info/licenses/LICENSE,sha256=_KRtksFg3PbSlgNIn1UoPfJu-VboyW2Phdo3oPyosCk,1508
7
+ requestium-0.5.1.dist-info/METADATA,sha256=WWKGloqUs6s2pOIuUT4sJT5-tedCAjTjLDewG3RW0Yg,18231
8
+ requestium-0.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ requestium-0.5.1.dist-info/top_level.txt,sha256=6lHJKsZ2tUqzyHVJWBZkIAq0OX8aTbMCTwqCd0yHdV0,11
10
+ requestium-0.5.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2017, Tryolabs
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ requestium