Coverage for amazonorders/session.py: 92.80%

125 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-14 16:09 +0000

1import logging 

2from io import BytesIO 

3 

4from PIL import Image 

5from bs4 import BeautifulSoup 

6from requests import Session 

7 

8__author__ = "Alex Laird" 

9__copyright__ = "Copyright 2024, Alex Laird" 

10__version__ = "0.0.4" 

11 

12from amazonorders.exception import AmazonOrdersAuthError 

13 

14logger = logging.getLogger(__name__) 

15 

16BASE_URL = "https://www.amazon.com" 

17BASE_HEADERS = { 

18 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", 

19 "Accept-Encoding": "gzip, deflate, br", 

20 "Accept-Language": "en-US,en;q=0.9", 

21 "Cache-Control": "max-age=0", 

22 "Content-Type": "application/x-www-form-urlencoded", 

23 "Origin": BASE_URL, 

24 "Referer": "{}/ap/signin".format(BASE_URL), 

25 "Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', 

26 "Sec-Ch-Ua-Mobile": "?0", 

27 "Sec-Ch-Ua-Platform": "macOS", 

28 "Sec-Ch-Viewport-Width": "1393", 

29 "Sec-Fetch-Dest": "document", 

30 "Sec-Fetch-Mode": "navigate", 

31 "Sec-Fetch-Site": "same-origin", 

32 "Sec-Fetch-User": "?1", 

33 "Viewport-Width": "1393", 

34 "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", 

35} 

36SIGN_IN_FORM_NAME = "signIn" 

37MFA_DEVICE_SELECT_FORM_ID = "auth-select-device-form" 

38MFA_FORM_ID = "auth-mfa-form" 

39CAPTCHA_DIV_ID = "cvf-page-content" 

40CAPTCHA_FORM_CLASS = "cvf-widget-form" 

41 

42 

43class AmazonSession: 

44 def __init__(self, 

45 username, 

46 password, 

47 debug=False, 

48 max_auth_attempts=10) -> None: 

49 self.username = username 

50 self.password = password 

51 

52 self.debug = debug 

53 self.max_auth_attempts = max_auth_attempts 

54 

55 self.session = Session() 

56 self.last_response = None 

57 self.last_response_parsed = None 

58 self.is_authenticated = False 

59 

60 def request(self, method, url, **kwargs): 

61 if "headers" not in kwargs: 

62 kwargs["headers"] = {} 

63 kwargs["headers"].update(BASE_HEADERS) 

64 

65 logger.debug("{} request to {}".format(method, url)) 

66 

67 self.last_response = self.session.request(method, url, **kwargs) 

68 self.last_response_parsed = BeautifulSoup(self.last_response.text, 

69 "html.parser") 

70 

71 logger.debug("Response: {} - {}".format(self.last_response.url, 

72 self.last_response.status_code)) 

73 

74 if self.debug: 

75 page_name = self._get_page_from_url(self.last_response.url) 

76 with open(page_name, "w") as html_file: 

77 html_file.write(self.last_response.text) 

78 

79 return self.last_response 

80 

81 def get(self, url, **kwargs): 

82 return self.request("GET", url, **kwargs) 

83 

84 def post(self, url, **kwargs): 

85 return self.request("POST", url, **kwargs) 

86 

87 def login(self): 

88 self.get("{}/gp/sign-in.html".format(BASE_URL)) 

89 

90 attempts = 0 

91 while not self.is_authenticated and attempts < self.max_auth_attempts: 

92 if self._is_form_found(SIGN_IN_FORM_NAME, attr_name="name"): 

93 self._sign_in() 

94 elif self._is_form_found(CAPTCHA_FORM_CLASS, attr_name="class"): 

95 self._captcha_submit() 

96 elif self._is_form_found(MFA_DEVICE_SELECT_FORM_ID): 

97 self._mfa_device_select() 

98 elif self._is_form_found(MFA_FORM_ID): 

99 self._mfa_submit() 

100 else: 

101 raise AmazonOrdersAuthError( 

102 "An error occurred, this is an unknown page: {}".format( 

103 self.last_response.url)) 

104 

105 if "Hello, sign in" not in self.last_response.text and "nav-item-signout" in self.last_response.text: 

106 self.is_authenticated = True 

107 else: 

108 attempts += 1 

109 

110 if attempts == self.max_auth_attempts: 

111 raise AmazonOrdersAuthError( 

112 "Max authentication flow attempts reached.") 

113 

114 def logout(self): 

115 self.get("{}/gp/sign-out.html".format(BASE_URL)) 

116 

117 self.close() 

118 

119 def close(self): 

120 self.session.close() 

121 

122 def _sign_in(self): 

123 data = self._build_from_form(SIGN_IN_FORM_NAME, 

124 {"email": self.username, 

125 "password": self.password, 

126 "rememberMe": "true"}, 

127 attr_name="name") 

128 

129 self.post(self._get_form_action(SIGN_IN_FORM_NAME), 

130 data=data) 

131 

132 self._handle_errors(critical=True) 

133 

134 def _mfa_device_select(self): 

135 form = self.last_response_parsed.find("form", 

136 {"id": MFA_DEVICE_SELECT_FORM_ID}) 

137 contexts = form.find_all("input", {"name": "otpDeviceContext"}) 

138 i = 1 

139 for field in contexts: 

140 print("{}: {}".format(i, field.attrs["value"].strip())) 

141 i += 1 

142 otp_device = int( 

143 input("Where would you like your one-time passcode sent? ")) 

144 

145 data = self._build_from_form(MFA_DEVICE_SELECT_FORM_ID, 

146 {"otpDeviceContext": 

147 contexts[otp_device - 1].attrs[ 

148 "value"]}) 

149 

150 self.post(self._get_form_action(MFA_DEVICE_SELECT_FORM_ID, attr_name="id"), 

151 data=data) 

152 

153 self._handle_errors() 

154 

155 def _mfa_submit(self): 

156 otp = input("Enter the one-time passcode sent to your device: ") 

157 

158 # TODO: figure out why Amazon doesn't respect rememberDevice 

159 data = self._build_from_form(MFA_FORM_ID, 

160 {"otpCode": otp, "rememberDevice": ""}) 

161 

162 self.post(self._get_form_action(MFA_FORM_ID, attr_name="id"), 

163 data=data) 

164 

165 self._handle_errors() 

166 

167 def _captcha_submit(self): 

168 captcha = self.last_response_parsed.find("div", {"id": CAPTCHA_DIV_ID}) 

169 

170 img_src = captcha.find("img", {"alt": "captcha"}).attrs["src"] 

171 img_response = self.session.get(img_src) 

172 img = Image.open(BytesIO(img_response.content)) 

173 img.show() 

174 

175 captcha_response = input("Enter the Captcha seen on the opened image: ") 

176 

177 data = self._build_from_form(CAPTCHA_FORM_CLASS, 

178 {"cvf_captcha_input": captcha_response}, 

179 attr_name="class") 

180 

181 self.post(self._get_form_action(CAPTCHA_FORM_CLASS, 

182 attr_name="class", 

183 prefix="{}/ap/cvf/".format(BASE_URL)), 

184 data=data) 

185 

186 self._handle_errors("cvf-widget-alert", "class") 

187 

188 def _build_from_form(self, form_name, additional_attrs, attr_name="id"): 

189 data = {} 

190 form = self.last_response_parsed.find("form", {attr_name: form_name}) 

191 for field in form.find_all("input"): 

192 try: 

193 data[field["name"]] = field["value"] 

194 except: 

195 pass 

196 data.update(additional_attrs) 

197 return data 

198 

199 def _get_form_action(self, form_name, attr_name="name", prefix=None): 

200 form = self.last_response_parsed.find("form", {attr_name: form_name}) 

201 action = form.attrs.get("action") 

202 if not action: 

203 action = self.last_response.url 

204 if prefix and "/" not in action: 

205 action = prefix + action 

206 return action 

207 

208 def _is_form_found(self, form_name, attr_name="id"): 

209 return self.last_response_parsed.find("form", { 

210 attr_name: form_name}) is not None 

211 

212 def _get_page_from_url(self, url): 

213 page_name = url.rsplit("/", 1)[-1].split("?")[0] 

214 if not page_name.endswith(".html"): 

215 page_name += ".html" 

216 return page_name 

217 

218 def _handle_errors(self, error_div="auth-error-message-box", attr_name="id", 

219 critical=False): 

220 error_div = self.last_response_parsed.find("div", 

221 {attr_name: error_div}) 

222 if error_div: 

223 error_msg = "An error occurred: {}".format(error_div.text.strip()) 

224 

225 if critical: 

226 raise AmazonOrdersAuthError(error_msg) 

227 else: 

228 print(error_msg)