26 August 2026

Automating a Cashback Offer Alert on a Cloudflare + Cognito Protected Site

I wanted a daily alert when a particular AliExpress bonus cashback offer reappears on Quidco — a UK cashback site. The offer shows up in a carousel on the logged-in homepage and disappears within a day or two. Catching it manually is unreliable. This is a write-up of building a fully automated checker that runs at 1 AM every night.

The Target

The Quidco homepage carousel shows rotating offers like "Bonus Cashback — AliExpress: Get a £7.50 Bonus when you opt in and spend £15 or more." I wanted to be notified the moment one of these appears, without having to check manually.

The page is:
- JavaScript-rendered (React/Next.js)
- Protected by Cloudflare bot detection
- Authenticated via AWS Cognito (short-lived JWTs, 1-hour TTL)

A simple curl or requests fetch gets a 403 immediately. So we need a real browser.

Tool: Playwright Firefox

Playwright is a browser automation library that drives real browser engines headlessly. The first instinct is Chromium — it's the default — but Cloudflare's cf_clearance cookie is bound to the TLS fingerprint (JA3 hash) of the browser that solved the challenge. My Firefox session's cf_clearance won't work in Chromium because the two engines produce different TLS ClientHello signatures.

Solution: use playwright's Firefox engine, which is close enough in fingerprint to the real Firefox that the cf_clearance transfers across.

with sync_playwright() as p:
    browser = p.firefox.launch(headless=True)

Problem 1: Cookies Weren't Being Sent

Firefox stores cookies in an SQLite database at snap/firefox/common/.mozilla/firefox/<profile>/cookies.sqlite. I read them and injected them into the playwright context — but the page kept redirecting to login.

Tracing the actual HTTP requests showed cf_clearance and session_id were missing from the Cookie header on requests to www.quidco.com, even though I'd injected them.

The bug: Firefox's SQLite host column uses a leading dot (.quidco.com) to signal subdomain-matching cookies, mirroring the Set-Cookie: Domain= attribute in RFC 6265. I was stripping that dot:

# Wrong — tells playwright "exact host only"
cookies.append({"domain": host.lstrip('.'), ...})

# Right — keep the dot so playwright sends it to www.quidco.com too
cookies.append({"domain": host, ...})

After that fix, all the right cookies arrived at the server and the page loaded.

Problem 2: Cognito Redirect Loop

The Cognito access token (stored as cognito_token cookie) has a 1-hour TTL. At 1 AM, if the user hasn't visited Quidco recently, it'll be stale. Sending a stale token caused an infinite redirect loop:

GET /home/           → 302 /?auth=login   (Cognito middleware: token expired)
GET /?auth=login     → 302 /home/         (session_id is valid, go home)
GET /home/           → 302 /?auth=login   (token still expired)
...

The fix is counterintuitive: don't send the expired token at all. When the token is missing rather than expired, the server's Cognito middleware steps aside and lets the client-side Amplify.js handle authentication instead.

if name == "cognito_token":
    if exp > now:
        cognito_expired = False
    else:
        continue  # omit it — sending it causes a redirect loop

Problem 3: Token Refresh via Amplify.js

With no cognito_token but a valid cognito_refresh_token (6-month TTL), the Quidco page's embedded AWS Amplify SDK detects the missing token on load and silently fetches a new one from Cognito using the refresh token. It then redirects the client to /home/ — entirely client-side, no server round-trip.

def refresh_cognito(ctx, page) -> bool:
    # Navigate to root (not /home/) — server accepts it without Cognito check
    page.goto("https://www.quidco.com/", wait_until="domcontentloaded")
    try:
        # Amplify.js fires, refreshes the token, redirects client to /home/
        page.wait_for_url("**/home/**", timeout=20_000)
        return True
    except TimeoutError:
        return False

I tested this with a genuinely expired token (12 minutes past expiry). The root page loaded, Amplify.js ran, a new token was silently obtained, and the browser landed on /home/ — all in the first page load.

Parsing the Carousel

The carousel cards are rendered as div.main elements with a div.main-title inside. BeautifulSoup makes extraction straightforward:

from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "html.parser")
offers = []
seen = set()
for card in soup.find_all("div", class_="main"):
    title_el = card.find("div", class_="main-title")
    if not title_el:
        continue
    title = title_el.get_text(strip=True)
    if title in seen:
        continue
    seen.add(title)
    desc_el = card.find("div", class_="main-description")
    offers.append({
        "title": title,
        "description": desc_el.get_text(strip=True) if desc_el else ""
    })

Importantly, I check the carousel titles specifically rather than searching full-page body text. Quidco also shows AliExpress in a "Your Favourites" section — a body text search would give false positives.

Alerts via Claude Push Notifications

For the 1 AM alert, I use the claude CLI in non-interactive mode to push a notification to the Android Claude app:

claude -p "Send a push notification: Quidco AliExpress offer on carousel — £7.50 bonus" \
    --allowedTools PushNotification

This spawns a lightweight Claude Code session that calls the PushNotification tool, which routes through to Claude's mobile app via Remote Control. No email credentials, no third-party push service.

The Cron Job

# crontab -l
0 0 * * * /home/user/claude/quidco/alert.sh

Midnight UTC = 1 AM BST. The carousel rolls over at midnight, so this catches whatever's new for the day.

Full Flow

cron (00:00 UTC)
  └─ alert.sh
       └─ quidco_check.py
            ├─ read Firefox cookies.sqlite
            ├─ [if token expired] playwright Firefox → quidco.com root
            │    └─ Amplify.js refreshes token → redirects to /home/
            ├─ [if token fresh] playwright Firefox → /home/ directly
            ├─ parse div.main carousel cards
            └─ return {"found": bool, "carousel": [...]}
  └─ [if found] claude -p "push notification"
  └─ [if found] notify-send (best-effort desktop)
  └─ log to check.log

Results

Current carousel on a typical day: Boots, Temu, IHG Hotels, Goldsmiths, Shepherds Friendly ISA, Antler, Opodo, LG Electronics, Quidco Gift Cards, Very, Quidco In-Store, Pooch and Mutt, Lovehoney, Virgin Experience Days.

When AliExpress appeared earlier today (£7.50 bonus, "Ends Today"), the script correctly detected it. After midnight when the offer expired, it correctly returned found: false.

The main fragility is the cognito_refresh_token — it has a ~6 month lifetime. When it expires, a fresh Firefox login to Quidco is all that's needed to re-establish the session.

Code

agents/quidco_check.py — about 100 lines of Python. Dependencies: playwright, beautifulsoup4 (both already available in the project venv).

No comments:

Post a Comment