Coverage for amazonorders/forms.py: 96.15%
130 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-01-29 14:37 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-01-29 14:37 +0000
1from abc import ABC
2from io import BytesIO
3from typing import Optional, Dict, Any
4from urllib.parse import urlparse
6from PIL import Image
7from amazoncaptcha import AmazonCaptcha
8from bs4 import Tag
10from amazonorders import constants
11from amazonorders.exception import AmazonOrdersError, AmazonOrdersAuthError
13__author__ = "Alex Laird"
14__copyright__ = "Copyright 2024, Alex Laird"
15__version__ = "1.0.7"
18class AuthForm(ABC):
19 """
20 The base class of an authentication ``<form>`` that can be submitted.
21 """
23 def __init__(self,
24 selector: str,
25 error_selector: str = constants.DEFAULT_ERROR_TAG_SELECTOR,
26 critical: bool = False) -> None:
27 #: The CSS selector for the ``<form>``.
28 self.selector: str = selector
29 #: The CSS selector for the error div when form submission fails.
30 self.error_selector: str = error_selector
31 #: If ``critical``, form submission failures will raise :class:`~amazonorders.exception.AmazonOrdersAuthError`.
32 self.critical: bool = critical
33 #: The :class:`~amazonorders.session.AmazonSession` on which to submit the form.
34 self.amazon_session = None
35 #: The selected ``<form>``.
36 self.form: Optional[Tag] = None
37 #: The ``<form>`` data that will be submitted.
38 self.data: Optional[Dict[Any]] = None
40 def select_form(self,
41 amazon_session,
42 parsed: Tag) -> bool:
43 """
44 Using the ``selector`` defined on this instance, select the ``<form>`` for the given :class:`~bs4.Tag`.
46 :param amazon_session: The ``AmazonSession`` on which to submit the form.
47 :param parsed: The ``Tag`` from which to select the ``<form>``.
48 :return: Whether the ``<form>`` selection was successful.
49 """
50 self.amazon_session = amazon_session
51 self.form = parsed.select_one(self.selector)
53 return self.form is not None
55 def fill_form(self,
56 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
57 """
58 Populate the ``data`` field with values from the ``<form>``, including any additional attributes passed.
60 :param additional_attrs: Additional attributes to add to the ``<form>`` data for submission.
61 """
62 if not self.form:
63 raise AmazonOrdersError("Call AuthForm.select_form() first.")
65 self.data = {}
66 for field in self.form.select("input"):
67 try:
68 self.data[field["name"]] = field["value"]
69 except:
70 pass
71 if additional_attrs:
72 self.data.update(additional_attrs)
74 def submit(self) -> None:
75 """
76 Submit the populated ``<form>``.
77 """
78 if not self.form:
79 raise AmazonOrdersError("Call AuthForm.select_form() first.")
80 elif not self.data:
81 raise AmazonOrdersError("Call AuthForm.fill_form() first.")
83 method = self.form.get("method", "GET").upper()
84 action = self._get_form_action()
85 request_data = {"params" if method == "GET" else "data": self.data}
86 self.amazon_session.request(method,
87 action,
88 **request_data)
90 self._handle_errors()
92 self.clear_form()
94 def clear_form(self) -> None:
95 """
96 Clear the populated ``<form>`` so this class can be reused.
97 """
98 self.amazon_session = None
99 self.form = None
100 self.data = None
102 def _solve_captcha(self,
103 url: str) -> str:
104 captcha_response = AmazonCaptcha.fromlink(url).solve()
105 if not captcha_response or captcha_response.lower() == "not solved":
106 img_response = self.amazon_session.session.get(url)
107 img = Image.open(BytesIO(img_response.content))
108 img.show()
110 self.amazon_session.io.echo("Info: The Captcha couldn't be auto-solved.")
112 captcha_response = self.amazon_session.io.prompt("--> Enter the characters shown in the image")
113 self.amazon_session.io.echo("")
115 return captcha_response
117 def _get_form_action(self) -> str:
118 action = self.form.get("action")
119 if not action:
120 return self.amazon_session.last_response.url
121 elif not action.startswith("http"):
122 if action.startswith("/"):
123 parsed_url = urlparse(self.amazon_session.last_response.url)
124 return "{}://{}{}".format(parsed_url.scheme, parsed_url.netloc,
125 action)
126 else:
127 return "{}/{}".format(
128 "/".join(self.amazon_session.last_response.url.split("/")[:-1]), action)
129 else:
130 return action
132 def _handle_errors(self) -> None:
133 error_tag = self.amazon_session.last_response_parsed.select_one(self.error_selector)
134 if error_tag:
135 error_msg = "An error occurred: {}\n".format(error_tag.text.strip())
137 if self.critical:
138 raise AmazonOrdersAuthError(error_msg)
139 else:
140 self.amazon_session.io.echo(error_msg, fg="red")
143class SignInForm(AuthForm):
144 def __init__(self,
145 selector: str = constants.SIGN_IN_FORM_SELECTOR,
146 solution_attr_key: str = "email") -> None:
147 super().__init__(selector, critical=True)
149 self.solution_attr_key = solution_attr_key
151 def fill_form(self,
152 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
153 if not additional_attrs:
154 additional_attrs = {}
155 super().fill_form()
157 additional_attrs.update({self.solution_attr_key: self.amazon_session.username,
158 "password": self.amazon_session.password,
159 "rememberMe": "true"})
160 self.data.update(additional_attrs)
163class MfaDeviceSelectForm(AuthForm):
164 def __init__(self,
165 selector: str = constants.MFA_DEVICE_SELECT_FORM_SELECTOR,
166 solution_attr_key: str = "otpDeviceContext") -> None:
167 super().__init__(selector)
169 self.solution_attr_key = solution_attr_key
171 def fill_form(self,
172 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
173 if not additional_attrs:
174 additional_attrs = {}
175 super().fill_form()
177 contexts = self.form.select(constants.MFA_DEVICE_SELECT_INPUT_SELECTOR)
178 i = 1
179 for field in contexts:
180 self.amazon_session.io.echo("{}: {}".format(i, field["value"].strip()))
181 i += 1
183 otp_device = int(
184 self.amazon_session.io.prompt("--> Enter where you would like your one-time passcode sent",
185 type=int)
186 )
187 self.amazon_session.io.echo("")
189 additional_attrs.update({self.solution_attr_key: contexts[otp_device - 1]["value"]})
190 self.data.update(additional_attrs)
193class MfaForm(AuthForm):
194 def __init__(self,
195 selector: str = constants.MFA_FORM_SELECTOR,
196 solution_attr_key: str = "otpCode") -> None:
197 super().__init__(selector)
199 self.solution_attr_key = solution_attr_key
201 def fill_form(self,
202 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
203 if not additional_attrs:
204 additional_attrs = {}
205 super().fill_form()
207 otp = self.amazon_session.io.prompt("--> Enter the one-time passcode sent to your device")
208 self.amazon_session.io.echo("")
210 additional_attrs.update({self.solution_attr_key: otp,
211 "rememberDevice": ""})
212 self.data.update(additional_attrs)
215class CaptchaForm(AuthForm):
216 def __init__(self,
217 selector: str = constants.CAPTCHA_1_FORM_SELECTOR,
218 error_selector: str = constants.CAPTCHA_1_ERROR_SELECTOR,
219 solution_attr_key: str = "cvf_captcha_input") -> None:
220 super().__init__(selector, error_selector)
222 self.solution_attr_key = solution_attr_key
224 def fill_form(self,
225 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
226 if not additional_attrs:
227 additional_attrs = {}
228 super().fill_form(additional_attrs)
230 # TODO: eliminate the use of find_parent() here
231 img_url = self.form.find_parent().select_one("img")["src"]
232 if not img_url.startswith("http"):
233 img_url = "{}{}".format(constants.BASE_URL, img_url)
234 solution = self._solve_captcha(img_url)
236 additional_attrs.update({self.solution_attr_key: solution})
237 self.data.update(additional_attrs)