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

1from abc import ABC 

2from io import BytesIO 

3from typing import Optional, Dict, Any 

4from urllib.parse import urlparse 

5 

6from PIL import Image 

7from amazoncaptcha import AmazonCaptcha 

8from bs4 import Tag 

9 

10from amazonorders import constants 

11from amazonorders.exception import AmazonOrdersError, AmazonOrdersAuthError 

12 

13__author__ = "Alex Laird" 

14__copyright__ = "Copyright 2024, Alex Laird" 

15__version__ = "1.0.9" 

16 

17 

18class AuthForm(ABC): 

19 """ 

20 The base class of an authentication ``<form>`` that can be submitted. 

21 

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 """ 

26 

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 

43 

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`. 

49 

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) 

56 

57 return self.form is not None 

58 

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. 

63 

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.") 

68 

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) 

77 

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.") 

86 

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) 

93 

94 self._handle_errors() 

95 

96 self.clear_form() 

97 

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 

105 

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() 

113 

114 self.amazon_session.io.echo("Info: The Captcha couldn't be auto-solved.") 

115 

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("") 

119 

120 return captcha_response 

121 

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 

136 

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()) 

141 

142 if self.critical: 

143 raise AmazonOrdersAuthError(error_msg) 

144 else: 

145 self.amazon_session.io.echo(error_msg, fg="red") 

146 

147 

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) 

153 

154 self.solution_attr_key = solution_attr_key 

155 

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() 

161 

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) 

166 

167 

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: 

174 

175 .. code:: python 

176 

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 

181 

182 i += 1 

183 """ 

184 

185 def __init__(self, 

186 selector: str = constants.MFA_DEVICE_SELECT_FORM_SELECTOR, 

187 solution_attr_key: str = "otpDeviceContext") -> None: 

188 super().__init__(selector) 

189 

190 self.solution_attr_key = solution_attr_key 

191 

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() 

197 

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 

203 

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("") 

210 

211 additional_attrs.update({self.solution_attr_key: contexts[otp_device - 1]["value"]}) 

212 self.data.update(additional_attrs) 

213 

214 

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) 

220 

221 self.solution_attr_key = solution_attr_key 

222 

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() 

228 

229 otp = self.amazon_session.io.prompt("Enter the one-time passcode sent to your device") 

230 self.amazon_session.io.echo("") 

231 

232 additional_attrs.update({self.solution_attr_key: otp, 

233 "rememberDevice": ""}) 

234 self.data.update(additional_attrs) 

235 

236 

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) 

243 

244 self.solution_attr_key = solution_attr_key 

245 

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) 

251 

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) 

257 

258 additional_attrs.update({self.solution_attr_key: solution}) 

259 self.data.update(additional_attrs)