Skip to content

Commit

Permalink
Merge pull request #82 from smkent/selenium
Browse files Browse the repository at this point in the history
Perform Safeway account sign in using undetected-chromedriver
  • Loading branch information
smkent authored Jul 10, 2023
2 parents b8c8149 + c090269 commit d965c1d
Show file tree
Hide file tree
Showing 14 changed files with 653 additions and 221 deletions.
21 changes: 18 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
FROM python:3-alpine
FROM busybox

FROM python:3
ARG POETRY_DYNAMIC_VERSIONING_BYPASS="0.0.0"
ENV CRON_SCHEDULE "5 2 * * *"
ENV SMTPHOST=
Expand All @@ -7,12 +9,25 @@ ENV SAFEWAY_ACCOUNT_PASSWORD=
ENV SAFEWAY_ACCOUNT_MAIL_FROM=
ENV SAFEWAY_ACCOUNT_MAIL_TO=
ENV SAFEWAY_ACCOUNTS_FILE=
ENV DEBUG_DIR="/debug"

RUN DEBIAN_FRONTEND=noninteractive && \
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub > /usr/share/keyrings/chrome.pub && \
echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/chrome.pub] http://dl.google.com/linux/chrome/deb/ stable main' > /etc/apt/sources.list.d/google-chrome.list && \
apt update -y && \
apt install -y google-chrome-stable
RUN apt install -y tini

# Install busybox utilities using static binary from official image
COPY --from=busybox /bin/busybox /bin/busybox
RUN for target in /usr/sbin/sendmail /usr/sbin/crond /usr/bin/crontab; do \
ln -svf /bin/busybox ${target}; \
done

RUN apk add --no-cache tini
COPY docker/entrypoint /

COPY . /python-build
RUN python3 -m pip install /python-build && rm -rf /python-build

ENTRYPOINT ["/sbin/tini", "--"]
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/entrypoint"]
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
and attempt to select all of the "Safeway for U" electronic coupons on the site
so they don't have to each be clicked manually.

## Design notes

Safeway's sign in page is protected by a web application firewall (WAF).
safeway-coupons performs authentication using a headless instance of Google
Chrome. Authentication may fail based on your IP's reputation, either by
presenting a CAPTCHA or denying sign in attempts altogether. safeway-coupons
currently does not have support for prompting the user to solve CAPTCHAs.

Once a signed in session is established, coupon clipping is performed using HTTP
requests via [requests][requests].

## Installation and usage with Docker

A Docker container is provided which runs safeway-coupons with cron. The cron
Expand Down Expand Up @@ -65,18 +76,23 @@ docker-compose logs -f

## Installation from PyPI

### Prerequisites

* Google Chrome (for authentication performed via Selenium).
* Optional: `sendmail` (for email support)

### Installation

[safeway-coupons is available on PyPI][pypi]:

```console
pip install safeway-coupons
```

`sendmail` is needed for email support.
### Usage

For best results, run this program once a day or so with a cron daemon.

### Usage

For full usage options, run

```console
Expand Down Expand Up @@ -163,3 +179,4 @@ Created from [smkent/cookie-python][cookie-python] using
[poetry]: https://python-poetry.org/docs/#installation
[pypi]: https://pypi.org/project/safeway-coupons/
[repo]: https://github.com/smkent/safeway-coupons
[requests]: https://requests.readthedocs.io/en/latest/
10 changes: 7 additions & 3 deletions docker/entrypoint
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ else
env | grep -vie 'password' | grep -e '^SAFEWAY_' -e '^SMTPHOST='
fi

mkdir /etc/cron.d
if [ -d "${DEBUG_DIR}" ]; then
args="${args} --debug-dir ${DEBUG_DIR}"
fi

mkdir -vp /etc/cron.d /var/spool/cron/crontabs
(
echo "${CRON_SCHEDULE?} safeway-coupons ${args} >/proc/1/fd/1 2>/proc/1/fd/2"
) > /var/spool/cron/crontabs/root

crontab -l
busybox crontab -l

exec crond -f -d 8
exec busybox crond -f
398 changes: 394 additions & 4 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ classifiers = [
python = "^3.8"
dataclasses-json = "*"
requests = "*"
selenium = "^4.10"
webdriver-manager = "^3.8.6"
undetected-chromedriver = "^3.5.0"

[tool.poetry.dev-dependencies]
bandit = {extras = ["toml"], version = "*"}
Expand Down
13 changes: 13 additions & 0 deletions safeway_coupons/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import sys
from http.client import HTTPConnection
from pathlib import Path

from .config import Config
from .safeway import SafewayCoupons
Expand All @@ -19,6 +20,17 @@ def _parse_args() -> argparse.Namespace:
"accounts information"
),
)
arg_parser.add_argument(
"-D",
"--debug-dir",
dest="debug_dir",
metavar="directory",
default=".",
help=(
"Destination directory for debug output files, "
"such as browser screenshots (default: %(default)s)"
),
)
arg_parser.add_argument(
"-d",
"--debug",
Expand Down Expand Up @@ -79,6 +91,7 @@ def main() -> None:
sc = SafewayCoupons(
send_email=args.send_email,
debug_level=args.debug_level,
debug_dir=Path(args.debug_dir) if args.debug_dir else None,
sleep_level=args.sleep_level,
dry_run=args.dry_run,
max_clip_count=args.max_clip_count,
Expand Down
5 changes: 3 additions & 2 deletions safeway_coupons/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import random
from pathlib import Path
from typing import List, Optional

import requests
Expand All @@ -12,8 +13,8 @@


class SafewayClient(BaseSession):
def __init__(self, account: Account) -> None:
self.session = LoginSession(account)
def __init__(self, account: Account, debug_dir: Optional[Path]) -> None:
self.session = LoginSession(account, debug_dir)
self.requests.headers.update(
{
"Authorization": f"Bearer {self.session.access_token}",
Expand Down
34 changes: 27 additions & 7 deletions safeway_coupons/email.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import collections
import mimetypes
import os
import subprocess
from email.mime.text import MIMEText
from email.message import EmailMessage
from pathlib import Path
from typing import List, Optional

from .accounts import Account
Expand All @@ -15,6 +17,7 @@ def _send_email(
mail_message: List[str],
debug_level: int,
send_email: bool,
attachments: Optional[List[Path]] = None,
) -> None:
mail_message_str = os.linesep.join(mail_message)
if debug_level >= 1:
Expand All @@ -27,16 +30,26 @@ def _send_email(
print("<<<<<<")
if not send_email:
return
email_data = MIMEText(mail_message_str)
email_data["To"] = account.mail_to
email_data["From"] = account.mail_from
msg = EmailMessage()
msg["To"] = account.mail_to
msg["From"] = account.mail_from
if subject:
email_data["Subject"] = subject
msg["Subject"] = subject
msg.set_content(mail_message_str)
for attachment in attachments or []:
mt = mimetypes.guess_type(attachment.name)[0]
main, sub = mt.split("/", 1) if mt else ("application", "octet-stream")
msg.add_attachment(
attachment.read_bytes(),
filename=attachment.name,
maintype=main,
subtype=sub,
)
p = subprocess.Popen(
["/usr/sbin/sendmail", "-f", account.mail_to, "-t"],
stdin=subprocess.PIPE,
)
p.communicate(bytes(email_data.as_string(), "UTF-8"))
p.communicate(bytes(msg.as_string(), "UTF-8"))


def email_clip_results(
Expand Down Expand Up @@ -77,4 +90,11 @@ def email_error(
mail_message += ["Clipped coupons:", ""]
for offer in error.clipped_offers:
mail_message += str(offer)
_send_email(account, mail_subject, mail_message, debug_level, send_email)
_send_email(
account,
mail_subject,
mail_message,
debug_level,
send_email,
attachments=getattr(error, "attachments", None),
)
2 changes: 2 additions & 0 deletions safeway_coupons/errors.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional

import requests
Expand All @@ -15,6 +16,7 @@ class Error(Exception):
@dataclass
class AuthenticationFailure(Error):
account: Account
attachments: Optional[List[Path]] = None

def __str__(self) -> str:
return f"Authentication Failure ({self.exception})"
Expand Down
11 changes: 7 additions & 4 deletions safeway_coupons/safeway.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import List
from pathlib import Path
from typing import List, Optional

from .accounts import Account
from .client import SafewayClient
Expand All @@ -15,24 +16,26 @@ def __init__(
self,
send_email: bool = True,
debug_level: int = 0,
debug_dir: Optional[Path] = None,
sleep_level: int = 0,
dry_run: bool = False,
max_clip_count: int = 0,
max_clip_errors: int = CLIP_ERROR_MAX,
) -> None:
self.send_email = send_email
self.debug_level = debug_level
self.debug_dir = debug_dir
self.sleep_level = sleep_level
self.dry_run = dry_run
self.max_clip_count = max_clip_count
self.max_clip_errors = max_clip_errors

def clip_for_account(self, account: Account) -> None:
print(f"Clipping coupons for Safeway account {account.username}")
swy = SafewayClient(account)
clipped_offers: List[Offer] = []
clip_errors: List[ClipError] = []
try:
swy = SafewayClient(account, self.debug_dir)
clipped_offers: List[Offer] = []
clip_errors: List[ClipError] = []
offers = swy.get_offers()
unclipped_offers = [
o for o in offers if o.status == OfferStatus.Unclipped
Expand Down
Loading

0 comments on commit d965c1d

Please sign in to comment.