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
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-14 16:09 +0000
1import logging
2from io import BytesIO
4from PIL import Image
5from bs4 import BeautifulSoup
6from requests import Session
8__author__ = "Alex Laird"
9__copyright__ = "Copyright 2024, Alex Laird"
10__version__ = "0.0.4"
12from amazonorders.exception import AmazonOrdersAuthError
14logger = logging.getLogger(__name__)
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"
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
52 self.debug = debug
53 self.max_auth_attempts = max_auth_attempts
55 self.session = Session()
56 self.last_response = None
57 self.last_response_parsed = None
58 self.is_authenticated = False
60 def request(self, method, url, **kwargs):
61 if "headers" not in kwargs:
62 kwargs["headers"] = {}
63 kwargs["headers"].update(BASE_HEADERS)
65 logger.debug("{} request to {}".format(method, url))
67 self.last_response = self.session.request(method, url, **kwargs)
68 self.last_response_parsed = BeautifulSoup(self.last_response.text,
69 "html.parser")
71 logger.debug("Response: {} - {}".format(self.last_response.url,
72 self.last_response.status_code))
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)
79 return self.last_response
81 def get(self, url, **kwargs):
82 return self.request("GET", url, **kwargs)
84 def post(self, url, **kwargs):
85 return self.request("POST", url, **kwargs)
87 def login(self):
88 self.get("{}/gp/sign-in.html".format(BASE_URL))
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))
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
110 if attempts == self.max_auth_attempts:
111 raise AmazonOrdersAuthError(
112 "Max authentication flow attempts reached.")
114 def logout(self):
115 self.get("{}/gp/sign-out.html".format(BASE_URL))
117 self.close()
119 def close(self):
120 self.session.close()
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")
129 self.post(self._get_form_action(SIGN_IN_FORM_NAME),
130 data=data)
132 self._handle_errors(critical=True)
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? "))
145 data = self._build_from_form(MFA_DEVICE_SELECT_FORM_ID,
146 {"otpDeviceContext":
147 contexts[otp_device - 1].attrs[
148 "value"]})
150 self.post(self._get_form_action(MFA_DEVICE_SELECT_FORM_ID, attr_name="id"),
151 data=data)
153 self._handle_errors()
155 def _mfa_submit(self):
156 otp = input("Enter the one-time passcode sent to your device: ")
158 # TODO: figure out why Amazon doesn't respect rememberDevice
159 data = self._build_from_form(MFA_FORM_ID,
160 {"otpCode": otp, "rememberDevice": ""})
162 self.post(self._get_form_action(MFA_FORM_ID, attr_name="id"),
163 data=data)
165 self._handle_errors()
167 def _captcha_submit(self):
168 captcha = self.last_response_parsed.find("div", {"id": CAPTCHA_DIV_ID})
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()
175 captcha_response = input("Enter the Captcha seen on the opened image: ")
177 data = self._build_from_form(CAPTCHA_FORM_CLASS,
178 {"cvf_captcha_input": captcha_response},
179 attr_name="class")
181 self.post(self._get_form_action(CAPTCHA_FORM_CLASS,
182 attr_name="class",
183 prefix="{}/ap/cvf/".format(BASE_URL)),
184 data=data)
186 self._handle_errors("cvf-widget-alert", "class")
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
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
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
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
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())
225 if critical:
226 raise AmazonOrdersAuthError(error_msg)
227 else:
228 print(error_msg)