Skip to content

API Reference

autogroceries.shopper.base

Shopper

Bases: ABC

Abstract base class for a shopper.

Handles the Playwright and logger setup.

Parameters:

Name Type Description Default
username str

Username for the shopping account.

required
password str

Password for the shopping account.

required
log_path Path | None

Optional. If provided, will output log to this path.

None
Source code in src/autogroceries/shopper/base.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class Shopper(ABC):
    """
    Abstract base class for a shopper.

    Handles the Playwright and logger setup.

    Args:
        username: Username for the shopping account.
        password: Password for the shopping account.
        log_path: Optional. If provided, will output log to this path.
    """

    USER_AGENT = (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
        "(KHTML, like Gecko) Version/17.6 Safari/605.1.15"
    )

    def __init__(
        self, username: str, password: str, log_path: Path | None = None
    ) -> None:
        self.username = username
        self.password = password
        self.logger = setup_logger(log_path)

    def setup_page(self, p: Playwright) -> Page:
        """
        Setup a Playwright page with configuration.

        Args:
            p: A Playwright instance.

        Returns:
            Playwright page with user agent and context.
        """
        browser = p.chromium.launch(
            headless=False,
            args=[
                "--disable-blink-features=AutomationControlled",
                "--no-sandbox",
                "--disable-dev-shm-usage",
            ],
        )

        context = browser.new_context(
            user_agent=self.USER_AGENT,
            viewport={"width": 1366, "height": 768},
            locale="en-GB",
            timezone_id="Europe/London",
        )

        return context.new_page()

    @abstractmethod
    def shop(self, ingredients: dict[str, int]) -> None:
        """
        Shop for ingredients.

        Args:
            ingredients: Keys are the ingredients to add to the basket and values are
                the desired quantity of each ingredient.
        """
        pass

setup_page(p)

Setup a Playwright page with configuration.

Parameters:

Name Type Description Default
p Playwright

A Playwright instance.

required

Returns:

Type Description
Page

Playwright page with user agent and context.

Source code in src/autogroceries/shopper/base.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def setup_page(self, p: Playwright) -> Page:
    """
    Setup a Playwright page with configuration.

    Args:
        p: A Playwright instance.

    Returns:
        Playwright page with user agent and context.
    """
    browser = p.chromium.launch(
        headless=False,
        args=[
            "--disable-blink-features=AutomationControlled",
            "--no-sandbox",
            "--disable-dev-shm-usage",
        ],
    )

    context = browser.new_context(
        user_agent=self.USER_AGENT,
        viewport={"width": 1366, "height": 768},
        locale="en-GB",
        timezone_id="Europe/London",
    )

    return context.new_page()

shop(ingredients) abstractmethod

Shop for ingredients.

Parameters:

Name Type Description Default
ingredients dict[str, int]

Keys are the ingredients to add to the basket and values are the desired quantity of each ingredient.

required
Source code in src/autogroceries/shopper/base.py
61
62
63
64
65
66
67
68
69
70
@abstractmethod
def shop(self, ingredients: dict[str, int]) -> None:
    """
    Shop for ingredients.

    Args:
        ingredients: Keys are the ingredients to add to the basket and values are
            the desired quantity of each ingredient.
    """
    pass

autogroceries.shopper.sainsburys

SainsburysShopper

Bases: Shopper

Shops for ingredients at Sainsbury's.

init is inherited from the autogroceries.shopper.base.Shopper abstract base class.

Source code in src/autogroceries/shopper/sainsburys.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
class SainsburysShopper(Shopper):
    """
    Shops for ingredients at Sainsbury's.

    __init__ is inherited from the `autogroceries.shopper.base.Shopper` abstract base
    class.
    """

    URL = "https://www.sainsburys.co.uk"

    def shop(self, ingredients: dict[str, int]) -> None:
        """
        Shop for ingredients at Sainsbury's.

        Args:
            ingredients: Keys are the ingredients to add to the basket and values are
                the desired quantity of each ingredient.
        """
        self.logger.info("----- Shopping at Sainsbury's -----")

        with sync_playwright() as p:
            self.page = self.setup_page(p)

            self.page.goto(self.URL)
            self._handle_cookies()

            self._go_to_login()
            self._handle_cookies()

            self._login()
            self._check_two_factor()
            self._check_empty_basket()

            for ingredient, n in ingredients.items():
                self._add_ingredient(ingredient, n)

        self.logger.info("----- Done -----")

    @delay
    def _handle_cookies(self) -> None:
        """
        Handle the cookie pop up, which otherwise masks the rest of the page.
        """
        try:
            button_selector = "button:has-text('Continue without accepting')"
            self.page.wait_for_selector(button_selector, timeout=3000)
            self.page.locator(button_selector).click()
            self.logger.info("Rejecting cookies")
        except TimeoutError:
            self.logger.info("No cookies popup found")
            pass

    @delay
    def _go_to_login(self) -> None:
        """
        Go to the login page.
        """
        self.page.locator("text=Log in").click()
        self.page.locator("text=Groceries account").click()

    @delay
    def _login(self) -> None:
        """
        Login with the provided username and password.
        """
        self.page.type("#username", self.username, delay=50)
        self.page.type("#password", self.password, delay=50)
        self.page.locator("button:has-text('Log in')").click()

    @delay
    def _check_two_factor(self) -> None:
        """
        Check if two-factor authentication is required.

        Raises:
            TwoFactorAuthenticationRequiredError: If required, user must manually login
                to their account first.
        """
        try:
            self.page.wait_for_selector(
                "text=Enter the code sent to your phone", timeout=3000
            )
            raise TwoFactorAuthenticationRequiredError(
                "Two-factor authentication required. Please login to your account "
                "manually then rerun autogroceries."
            )
        except TimeoutError:
            self.logger.info("Login successful (no two-factor authentication required)")
            pass

    @delay
    def _check_empty_basket(self) -> None:
        """
        Check if basket is initially empty.

        If basket not empty, autogroceries will error if it tries to add a product that
        is already in the basket.
        """
        if self.page.locator(".header-trolley ").count() > 0:
            self.logger.warning(
                "Basket is not initially empty. This may cause issues when adding products."
            )

    @delay
    def _add_ingredient(self, ingredient: str, n: int) -> None:
        """
        Search for and add product to basket matching a provided ingredient.

        Args:
            ingredient: The ingredient you would like to buy.
            n: The desired quantity of the ingredient.
        """
        # There are two search inputs on the same page, use the first.
        search_input = self.page.locator("#search-bar-input").first
        search_input.type(ingredient, delay=50)
        self.page.locator(".search-bar__button").first.click()

        try:
            # If no product found in 10s, skip this ingredient.
            self.page.wait_for_selector(
                ".product-tile-row",
                state="visible",
                timeout=10000,
            )

            products = self.page.locator('[data-testid^="product-tile-"]').all()

            selected_product = None
            for i, product in enumerate(products):
                # Only check the first 10 products.
                if i >= 10:
                    break

                # Default to selecting the first product.
                if i == 0:
                    selected_product = product

                # Prefer favourited products.
                if (
                    product.locator("button[data-testid='favourite-icon-full']").count()
                    > 0
                ):
                    selected_product = product
                    break

            if selected_product:
                product_name = selected_product.locator(
                    ".pt__info__description"
                ).text_content()
                self.logger.info(f"{n} {ingredient.title()}: {product_name}")

                for i in range(n):
                    if i == 0:
                        selected_product.locator(
                            "button[data-testid='add-button']"
                        ).click(delay=100)
                    else:
                        selected_product.locator(
                            "button[data-testid='pt-button-inc']"
                        ).click(delay=100)

        except TimeoutError:
            self.logger.warning(f"{n} {ingredient.title()}: no matching product found")

        search_input.clear()

shop(ingredients)

Shop for ingredients at Sainsbury's.

Parameters:

Name Type Description Default
ingredients dict[str, int]

Keys are the ingredients to add to the basket and values are the desired quantity of each ingredient.

required
Source code in src/autogroceries/shopper/sainsburys.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def shop(self, ingredients: dict[str, int]) -> None:
    """
    Shop for ingredients at Sainsbury's.

    Args:
        ingredients: Keys are the ingredients to add to the basket and values are
            the desired quantity of each ingredient.
    """
    self.logger.info("----- Shopping at Sainsbury's -----")

    with sync_playwright() as p:
        self.page = self.setup_page(p)

        self.page.goto(self.URL)
        self._handle_cookies()

        self._go_to_login()
        self._handle_cookies()

        self._login()
        self._check_two_factor()
        self._check_empty_basket()

        for ingredient, n in ingredients.items():
            self._add_ingredient(ingredient, n)

    self.logger.info("----- Done -----")

autogroceries.delay

delay(_func=None, *, delay=2)

Decorator that adds a random length delay before executing a function.

Intended to emulate human-like behaviour during browser interaction to respect rate limits.

Source code in src/autogroceries/delay.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def delay(_func=None, *, delay: int = 2) -> Callable:
    """
    Decorator that adds a random length delay before executing a function.

    Intended to emulate human-like behaviour during browser interaction to respect rate
    limits.
    """

    def decorator_delay(func):
        @functools.wraps(func)
        def wrapper_delay(*args, **kwargs):
            # Add a random delay (up to 0.5 seconds).
            time.sleep(delay + random.uniform(0, 0.5))
            return func(*args, **kwargs)

        return wrapper_delay

    if _func is None:
        return decorator_delay
    else:
        return decorator_delay(_func)

autogroceries.logging

setup_logger(log_path=None)

Setup logger.

Parameters:

Name Type Description Default
log_path Path | None

Optional. If provided, will output log to this path.

None

Returns:

Type Description
Logger

Logger with the desired configuration.

Source code in src/autogroceries/logging.py
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def setup_logger(log_path: Path | None = None) -> logging.Logger:
    """
    Setup logger.

    Args:
        log_path: Optional. If provided, will output log to this path.

    Returns:
        Logger with the desired configuration.
    """
    formatter = logging.Formatter(
        "%(asctime)s [%(levelname)s] - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
    )
    root_logger = logging.getLogger()

    if log_path:
        # Create directory in log_path if it does not exist.
        log_path.parent.mkdir(parents=True, exist_ok=True)
        file_handler = logging.FileHandler(log_path, encoding="utf-8")
        file_handler.setFormatter(formatter)
        root_logger.addHandler(file_handler)

    stream_handler = logging.StreamHandler()
    stream_handler.setFormatter(formatter)
    root_logger.addHandler(stream_handler)

    root_logger.setLevel(logging.INFO)

    return logging.getLogger(__name__)