Source code for amazonorders.forms

__copyright__ = "Copyright (c) 2024-2025 Alex Laird"
__license__ = "MIT"

import re
from abc import ABC
from io import BytesIO
from typing import Any, Dict, Optional, TYPE_CHECKING, Union
from urllib.parse import urlparse

import pyotp
from PIL import Image
from bs4 import Tag
from requests import Response

try:
    from amazoncaptcha import AmazonCaptcha
except ImportError:  # pragma: no cover
    AmazonCaptcha = None  # type: ignore[assignment, misc]

from amazonorders import util
from amazonorders.conf import AmazonOrdersConfig
from amazonorders.exception import AmazonOrdersAuthError, AmazonOrdersError
from amazonorders.util import AmazonSessionResponse

if TYPE_CHECKING:
    from amazonorders.session import AmazonSession


[docs] class AuthForm(ABC): """ The base class of an authentication ``<form>`` that can be submitted. The base implementation will attempt to auto-solve Captcha when the optional ``amazoncaptcha`` dependency is installed (``pip install amazon-orders[captcha]``, available on Python <=3.12 only). If auto-solve is unavailable or fails, it will use the default image view to show the Captcha prompt, and it will also pass the image URL to :func:`~amazonorders.session.IODefault.prompt` as ``img_url``. """ def __init__(self, config: AmazonOrdersConfig, selector: Optional[str], error_selector: Optional[str] = None, critical: bool = False) -> None: #: The config to use. self.config: AmazonOrdersConfig = config #: The CSS selector for the ``<form>``. self.selector: Optional[str] = selector #: The CSS selector for the error div when form submission fails. self.error_selector: str = error_selector or config.selectors.DEFAULT_ERROR_TAG_SELECTOR #: If ``True``, form submission failures will raise :class:`~amazonorders.exception.AmazonOrdersAuthError`. self.critical: bool = critical #: The :class:`~amazonorders.session.AmazonSession` on which to submit the form. self.amazon_session: Optional["AmazonSession"] = None #: The selected ``<form>``. self.form: Optional[Tag] = None #: The ``<form>`` data that will be submitted. self.data: Optional[Dict[str, Any]] = None
[docs] def select_form(self, amazon_session: "AmazonSession", parsed: Tag) -> bool: """ Using the ``selector`` defined on this instance, select the ``<form>`` for the given :class:`~bs4.Tag`. :param amazon_session: The ``AmazonSession`` on which to submit the form. :param parsed: The ``Tag`` from which to select the ``<form>``. :return: Whether the ``<form>`` selection was successful. """ if not self.selector: raise AmazonOrdersError("Must set a selector first.") # pragma: no cover self.amazon_session = amazon_session self.form = util.select_one(parsed, self.selector) return self.form is not None
[docs] def fill_form(self, additional_attrs: Optional[Dict[str, Any]] = None) -> None: """ Populate the ``data`` field with values from the ``<form>``, including any additional attributes passed. :param additional_attrs: Additional attributes to add to the ``<form>`` data for submission. """ if not self.form: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover self.data = {} for field in self.form.select("input"): try: self.data[str(field["name"])] = field["value"] except Exception: pass if additional_attrs: self.data.update(additional_attrs)
[docs] def submit(self, last_response: Response) -> AmazonSessionResponse: """ Submit the populated ``<form>``. :param last_response: The response of the request that fetched the form. :return: The response from the executed request. """ if not self.amazon_session or not self.form or not self.data: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover method = str(self.form.get("method", "GET")).upper() url = self._get_form_action(last_response) request_data = {"params" if method == "GET" else "data": self.data} form_response = self.amazon_session.request(method, url, persist_cookies=True, **request_data) self._handle_errors(form_response) self.clear_form() return form_response
[docs] def clear_form(self) -> None: """ Clear the populated ``<form>`` so this class can be reused. """ self.amazon_session = None self.form = None self.data = None
def _solve_captcha(self, url: str) -> Union[str, Any]: if not self.amazon_session: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover if AmazonCaptcha is not None: captcha_response = AmazonCaptcha.fromlink(url).solve() else: captcha_response = None if not captcha_response or captcha_response.lower() == "not solved": img_response = self.amazon_session.session.get(url) img = Image.open(BytesIO(img_response.content)) img.show() if AmazonCaptcha is None: self.amazon_session.io.echo( "Info: Captcha auto-solve is unavailable. Install with " "`pip install amazon-orders[captcha]` to enable it (only compatible with Python <=3.12.") else: self.amazon_session.io.echo("Info: The Captcha couldn't be auto-solved.") captcha_response = self.amazon_session.io.prompt("Enter the characters shown in the image", img_url=url) self.amazon_session.io.echo("") return captcha_response def _get_form_action(self, last_response: Response) -> str: if not self.amazon_session or not self.form: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover action = self.form.get("action") if not action: return last_response.url action = str(action) if not action.startswith("http"): if action.startswith("/"): parsed_url = urlparse(last_response.url) return f"{parsed_url.scheme}://{parsed_url.netloc}{action}" else: return "{url}/{path}".format(url="/".join(last_response.url.split("/")[:-1]), path=action) else: return action def _handle_errors(self, form_response: AmazonSessionResponse) -> None: if not self.amazon_session: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover error_tag = util.select_one(form_response.parsed, self.error_selector) if error_tag: error_msg = f"Error from Amazon: {util.cleanup_html_text(error_tag.text)}" if self.critical: raise AmazonOrdersAuthError(error_msg) else: self.amazon_session.io.echo(error_msg, fg="red")
[docs] class SignInForm(AuthForm): def __init__(self, config: AmazonOrdersConfig, selector: Optional[str] = None, solution_attr_key: str = "email") -> None: if not selector: selector = config.selectors.SIGN_IN_FORM_SELECTOR super().__init__(config, selector, critical=True) self.solution_attr_key = solution_attr_key
[docs] def fill_form(self, additional_attrs: Optional[Dict[str, Any]] = None) -> None: if not self.amazon_session: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover if not additional_attrs: additional_attrs = {} super().fill_form() if not self.data: raise AmazonOrdersError( "SignInForm data did not populate, but it's required. " "Check if Amazon changed their login flow." ) # pragma: no cover additional_attrs.update({self.solution_attr_key: self.amazon_session.username, "password": self.amazon_session.password, "rememberMe": "true"}) self.data.update(additional_attrs)
[docs] class ClaimForm(AuthForm): def __init__(self, config: AmazonOrdersConfig, selector: Optional[str] = None, solution_attr_key: str = "email") -> None: if not selector: selector = config.selectors.CLAIM_FORM_SELECTOR super().__init__(config, selector, critical=True) self.solution_attr_key = solution_attr_key
[docs] def fill_form(self, additional_attrs: Optional[Dict[str, Any]] = None) -> None: if not self.amazon_session: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover if not additional_attrs: additional_attrs = {} super().fill_form() if not self.data: raise AmazonOrdersError( "ClaimForm data did not populate, but it's required. " "Check if Amazon changed their login flow." ) # pragma: no cover additional_attrs.update({self.solution_attr_key: self.amazon_session.username, "password": self.amazon_session.password, "rememberMe": "true"}) self.data.update(additional_attrs)
[docs] class IntentForm(AuthForm): def __init__(self, config: AmazonOrdersConfig, selector: Optional[str] = None, error_selector: Optional[str] = None) -> None: if not selector: selector = config.selectors.INTENT_FORM_SELECTOR if not error_selector: error_selector = config.selectors.INTENT_MESSAGE_SELECTOR super().__init__(config, selector, error_selector, critical=True)
[docs] def submit(self, last_response: Response) -> AmazonSessionResponse: """ When we encounter this form, we can't submit it, so we display its contents as the error message, since within the context of this library, it is a termination event for the auth flow. :param last_response: The response of the request that fetched the form. :return: The response from the executed request. """ response = AmazonSessionResponse(last_response, self.config.bs4_parser) self._handle_errors(response) return response
[docs] class MfaDeviceSelectForm(AuthForm): """ This will first echo the ``<form>`` device choices, then it will pass the list of choices to :func:`~amazonorders.session.IODefault.prompt` as ``choices``. The value passed to :func:`~amazonorders.session.IODefault.prompt` will be a ``list`` of the ``value`` from each of ``input`` tag. """ def __init__(self, config: AmazonOrdersConfig, selector: Optional[str] = None, solution_attr_key: str = "otpDeviceContext") -> None: if not selector: selector = config.selectors.MFA_DEVICE_SELECT_FORM_SELECTOR super().__init__(config, selector) self.solution_attr_key = solution_attr_key
[docs] def fill_form(self, additional_attrs: Optional[Dict[str, Any]] = None) -> None: if not self.amazon_session or not self.form: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover if not additional_attrs: additional_attrs = {} super().fill_form() if not self.data: raise AmazonOrdersError( "MfaDeviceSelectForm data did not populate, but it's required. " "Check if Amazon changed their MFA flow." ) # pragma: no cover contexts = util.select(self.form, self.config.selectors.MFA_DEVICE_SELECT_INPUT_SELECTOR) i = 0 choices = [] for field in contexts: choices.append(f"{i}: {str(field[self.config.selectors.MFA_DEVICE_SELECT_INPUT_SELECTOR_VALUE]).strip()}") i += 1 otp_device = int( self.amazon_session.io.prompt("Choose where you would like your one-time passcode sent", type=int, choices=choices) ) self.amazon_session.io.echo("") additional_attrs.update({self.solution_attr_key: contexts[otp_device - 1]["value"]}) self.data.update(additional_attrs)
[docs] class MfaForm(AuthForm): def __init__(self, config: AmazonOrdersConfig, selector: Optional[str] = None, solution_attr_key: str = "otpCode") -> None: if not selector: selector = config.selectors.MFA_FORM_SELECTOR super().__init__(config, selector, critical=True) self.solution_attr_key = solution_attr_key
[docs] def fill_form(self, additional_attrs: Optional[Dict[str, Any]] = None) -> None: if not self.amazon_session: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover if not additional_attrs: additional_attrs = {} super().fill_form() if not self.data: raise AmazonOrdersError( "MfaForm data did not populate, but it's required. " "Check if Amazon changed their MFA flow." ) # pragma: no cover if self.amazon_session.otp_secret_key: otp = pyotp.TOTP(self.amazon_session.otp_secret_key.replace(" ", "")).now() else: otp = self.amazon_session.io.prompt("Enter the one-time passcode from your preferred 2FA method") self.amazon_session.io.echo("") additional_attrs.update({self.solution_attr_key: otp, "rememberDevice": ""}) self.data.update(additional_attrs) if "deviceId" not in self.data: self.data["deviceId"] = ""
[docs] class CaptchaForm(AuthForm): def __init__(self, config: AmazonOrdersConfig, selector: Optional[str] = None, error_selector: Optional[str] = None, solution_attr_key: str = "cvf_captcha_input") -> None: if not selector: selector = config.selectors.CAPTCHA_1_FORM_SELECTOR elif not error_selector: error_selector = config.selectors.CAPTCHA_1_ERROR_SELECTOR super().__init__(config, selector, error_selector) self.solution_attr_key = solution_attr_key
[docs] def fill_form(self, additional_attrs: Optional[Dict[str, Any]] = None) -> None: if not self.form: raise AmazonOrdersError( "Call AuthForm.select_form() first." ) # pragma: no cover if not additional_attrs: additional_attrs = {} super().fill_form(additional_attrs) if not self.data: raise AmazonOrdersError( "CaptchaForm data did not populate, but it's required. " "Check if Amazon changed their Captcha flow, and see " "https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login" ) # pragma: no cover # TODO: eliminate the use of find_parent() here form_parent = self.form.find_parent() if not form_parent: raise AmazonOrdersError( "CaptchaForm parent not found, but it's required. " "Check if Amazon changed their Captcha flow, and see " "https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login." ) # pragma: no cover img_tag = form_parent.select_one("img") solution_tag = form_parent.select_one(f"input[name='{self.solution_attr_key}']") if img_tag: img_url = str(img_tag["src"]) if not img_url.startswith("http"): img_url = f"{self.config.constants.BASE_URL}{img_url}" solution = self._solve_captcha(img_url) elif solution_tag: solution = str(solution_tag["value"]) else: raise AmazonOrdersError( f"CaptchaForm <img> or <input name='{self.solution_attr_key}']> tags not found, but one is required. " "Check if Amazon changed their Captcha flow, and see " "https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login." ) # pragma: no cover additional_attrs.update({self.solution_attr_key: solution}) self.data.update(additional_attrs)
[docs] class JSAuthBlocker(AuthForm): def __init__(self, config: AmazonOrdersConfig, regex: str) -> None: self.regex = regex super().__init__(config, None)
[docs] def select_form(self, amazon_session: "AmazonSession", parsed: Tag) -> bool: if not self.regex: raise AmazonOrdersError("Must set a regex first.") # pragma: no cover if re.search(self.regex, parsed.text): raise AmazonOrdersAuthError( "A JavaScript-based authentication challenge page has been found. This library cannot solve these " "challenges. See " "https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login for more details.") return False