Coverage for amazonorders/forms.py: 96.15%

130 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-01-30 14:24 +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.7" 

16 

17 

18class AuthForm(ABC): 

19 """ 

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

21 """ 

22 

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 

39 

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

45 

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) 

52 

53 return self.form is not None 

54 

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. 

59 

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

64 

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) 

73 

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

82 

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) 

89 

90 self._handle_errors() 

91 

92 self.clear_form() 

93 

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 

101 

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

109 

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

111 

112 captcha_response = self.amazon_session.io.prompt("--> Enter the characters shown in the image") 

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

114 

115 return captcha_response 

116 

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 

131 

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

136 

137 if self.critical: 

138 raise AmazonOrdersAuthError(error_msg) 

139 else: 

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

141 

142 

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) 

148 

149 self.solution_attr_key = solution_attr_key 

150 

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

156 

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) 

161 

162 

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) 

168 

169 self.solution_attr_key = solution_attr_key 

170 

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

176 

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 

182 

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

188 

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

190 self.data.update(additional_attrs) 

191 

192 

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) 

198 

199 self.solution_attr_key = solution_attr_key 

200 

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

206 

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

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

209 

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

211 "rememberDevice": ""}) 

212 self.data.update(additional_attrs) 

213 

214 

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) 

221 

222 self.solution_attr_key = solution_attr_key 

223 

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) 

229 

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) 

235 

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

237 self.data.update(additional_attrs)