Coverage for amazonorders/forms.py: 96.15%
130 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-07 21:56 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-07 21:56 +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.9"
18class AuthForm(ABC):
19 """
20 The base class of an authentication ``<form>`` that can be submitted.
22 The base implementation will attempt to auto-solve Captcha. If this fails, it will
23 use the default image view to show the Captcha prompt, and it will also pass the
24 image URL to :func:`~amazonorders.session.IODefault.prompt` as ``captcha_img_url``.
25 """
27 def __init__(self,
28 selector: str,
29 error_selector: str = constants.DEFAULT_ERROR_TAG_SELECTOR,
30 critical: bool = False) -> None:
31 #: The CSS selector for the ``<form>``.
32 self.selector: str = selector
33 #: The CSS selector for the error div when form submission fails.
34 self.error_selector: str = error_selector
35 #: If ``critical``, form submission failures will raise :class:`~amazonorders.exception.AmazonOrdersAuthError`.
36 self.critical: bool = critical
37 #: The :class:`~amazonorders.session.AmazonSession` on which to submit the form.
38 self.amazon_session = None
39 #: The selected ``<form>``.
40 self.form: Optional[Tag] = None
41 #: The ``<form>`` data that will be submitted.
42 self.data: Optional[Dict[Any]] = None
44 def select_form(self,
45 amazon_session,
46 parsed: Tag) -> bool:
47 """
48 Using the ``selector`` defined on this instance, select the ``<form>`` for the given :class:`~bs4.Tag`.
50 :param amazon_session: The ``AmazonSession`` on which to submit the form.
51 :param parsed: The ``Tag`` from which to select the ``<form>``.
52 :return: Whether the ``<form>`` selection was successful.
53 """
54 self.amazon_session = amazon_session
55 self.form = parsed.select_one(self.selector)
57 return self.form is not None
59 def fill_form(self,
60 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
61 """
62 Populate the ``data`` field with values from the ``<form>``, including any additional attributes passed.
64 :param additional_attrs: Additional attributes to add to the ``<form>`` data for submission.
65 """
66 if not self.form:
67 raise AmazonOrdersError("Call AuthForm.select_form() first.")
69 self.data = {}
70 for field in self.form.select("input"):
71 try:
72 self.data[field["name"]] = field["value"]
73 except:
74 pass
75 if additional_attrs:
76 self.data.update(additional_attrs)
78 def submit(self) -> None:
79 """
80 Submit the populated ``<form>``.
81 """
82 if not self.form:
83 raise AmazonOrdersError("Call AuthForm.select_form() first.")
84 elif not self.data:
85 raise AmazonOrdersError("Call AuthForm.fill_form() first.")
87 method = self.form.get("method", "GET").upper()
88 action = self._get_form_action()
89 request_data = {"params" if method == "GET" else "data": self.data}
90 self.amazon_session.request(method,
91 action,
92 **request_data)
94 self._handle_errors()
96 self.clear_form()
98 def clear_form(self) -> None:
99 """
100 Clear the populated ``<form>`` so this class can be reused.
101 """
102 self.amazon_session = None
103 self.form = None
104 self.data = None
106 def _solve_captcha(self,
107 url: str) -> str:
108 captcha_response = AmazonCaptcha.fromlink(url).solve()
109 if not captcha_response or captcha_response.lower() == "not solved":
110 img_response = self.amazon_session.session.get(url)
111 img = Image.open(BytesIO(img_response.content))
112 img.show()
114 self.amazon_session.io.echo("Info: The Captcha couldn't be auto-solved.")
116 captcha_response = self.amazon_session.io.prompt("Enter the characters shown in the image",
117 captcha_img_url=url)
118 self.amazon_session.io.echo("")
120 return captcha_response
122 def _get_form_action(self) -> str:
123 action = self.form.get("action")
124 if not action:
125 return self.amazon_session.last_response.url
126 elif not action.startswith("http"):
127 if action.startswith("/"):
128 parsed_url = urlparse(self.amazon_session.last_response.url)
129 return "{}://{}{}".format(parsed_url.scheme, parsed_url.netloc,
130 action)
131 else:
132 return "{}/{}".format(
133 "/".join(self.amazon_session.last_response.url.split("/")[:-1]), action)
134 else:
135 return action
137 def _handle_errors(self) -> None:
138 error_tag = self.amazon_session.last_response_parsed.select_one(self.error_selector)
139 if error_tag:
140 error_msg = "An error occurred: {}\n".format(error_tag.text.strip())
142 if self.critical:
143 raise AmazonOrdersAuthError(error_msg)
144 else:
145 self.amazon_session.io.echo(error_msg, fg="red")
148class SignInForm(AuthForm):
149 def __init__(self,
150 selector: str = constants.SIGN_IN_FORM_SELECTOR,
151 solution_attr_key: str = "email") -> None:
152 super().__init__(selector, critical=True)
154 self.solution_attr_key = solution_attr_key
156 def fill_form(self,
157 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
158 if not additional_attrs:
159 additional_attrs = {}
160 super().fill_form()
162 additional_attrs.update({self.solution_attr_key: self.amazon_session.username,
163 "password": self.amazon_session.password,
164 "rememberMe": "true"})
165 self.data.update(additional_attrs)
168class MfaDeviceSelectForm(AuthForm):
169 """
170 This will first echo the ``<form>`` device choices, then it will pass the list of choices
171 to :func:`~amazonorders.session.IODefault.prompt` as ``mfa_device_select_choices``. The value passed to
172 :func:`~amazonorders.session.IODefault.prompt` will be a ``list`` of :class:`~bs4.Tag` s, and here's
173 an example of turning each choice in to a ``str`` we can work with:
175 .. code:: python
177 i = 0
178 for field in mfa_device_select_choices:
179 choice_str = "{}: {}".format(i, field["value"].strip())
180 # ... Do something with the choice
182 i += 1
183 """
185 def __init__(self,
186 selector: str = constants.MFA_DEVICE_SELECT_FORM_SELECTOR,
187 solution_attr_key: str = "otpDeviceContext") -> None:
188 super().__init__(selector)
190 self.solution_attr_key = solution_attr_key
192 def fill_form(self,
193 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
194 if not additional_attrs:
195 additional_attrs = {}
196 super().fill_form()
198 contexts = self.form.select(constants.MFA_DEVICE_SELECT_INPUT_SELECTOR)
199 i = 1
200 for field in contexts:
201 self.amazon_session.io.echo("{}: {}".format(i, field["value"].strip()))
202 i += 1
204 otp_device = int(
205 self.amazon_session.io.prompt("Choose where you would like your one-time passcode sent",
206 type=int,
207 mfa_device_select_choices=contexts)
208 )
209 self.amazon_session.io.echo("")
211 additional_attrs.update({self.solution_attr_key: contexts[otp_device - 1]["value"]})
212 self.data.update(additional_attrs)
215class MfaForm(AuthForm):
216 def __init__(self,
217 selector: str = constants.MFA_FORM_SELECTOR,
218 solution_attr_key: str = "otpCode") -> None:
219 super().__init__(selector)
221 self.solution_attr_key = solution_attr_key
223 def fill_form(self,
224 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
225 if not additional_attrs:
226 additional_attrs = {}
227 super().fill_form()
229 otp = self.amazon_session.io.prompt("Enter the one-time passcode sent to your device")
230 self.amazon_session.io.echo("")
232 additional_attrs.update({self.solution_attr_key: otp,
233 "rememberDevice": ""})
234 self.data.update(additional_attrs)
237class CaptchaForm(AuthForm):
238 def __init__(self,
239 selector: str = constants.CAPTCHA_1_FORM_SELECTOR,
240 error_selector: str = constants.CAPTCHA_1_ERROR_SELECTOR,
241 solution_attr_key: str = "cvf_captcha_input") -> None:
242 super().__init__(selector, error_selector)
244 self.solution_attr_key = solution_attr_key
246 def fill_form(self,
247 additional_attrs: Optional[Dict[str, Any]] = None) -> None:
248 if not additional_attrs:
249 additional_attrs = {}
250 super().fill_form(additional_attrs)
252 # TODO: eliminate the use of find_parent() here
253 img_url = self.form.find_parent().select_one("img")["src"]
254 if not img_url.startswith("http"):
255 img_url = "{}{}".format(constants.BASE_URL, img_url)
256 solution = self._solve_captcha(img_url)
258 additional_attrs.update({self.solution_attr_key: solution})
259 self.data.update(additional_attrs)