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