Run it and a small preview window opens showing your webcam. While your face is pointed at the screen, a green box tracks it and the status reads FOCUSED. Tilt your head down to look at your phone and the box vanishes. Four seconds later the window turns red — NOT FOCUSED… APPLYING TO McDONALD'S — and jobs.mchire.com opens in your browser. Look back up and it forgives you instantly, with the console noting that the application remains open.
The interesting part is how little it takes. There is no pose estimation, no neural network and no machine learning of any kind. The whole thing is one 1990s face detector and a stopwatch, and it works because of a limitation rather than in spite of one.
The trick: a frontal-face Haar cascade only fires on faces pointed roughly at the camera. Tilt your head down and detection simply stops. So "no frontal face for N seconds" is "you are looking at your phone" — the classifier's most famous weakness, used as the signal.
Every stage below is in doomscroll_tracker.py. There are no other files, no config, and no state that outlives the process.
The script was written against pip install opencv-python. That command now resolves to OpenCV 5.0, and OpenCV 5.0 removed Haar cascades entirely — there is no cv2.CascadeClassifier and not one cascade XML in the wheel. Counted on this machine:
The install command is now pinned to "opencv-python<5" and the program checks for the class before it opens the camera, so the failure is one readable line instead of a traceback:
[tracker] OpenCV 5.0.0 has no Haar cascades — 5.0 removed them. [tracker] This script needs the 4.x line: pip install "opencv-python<5"
A webcam cannot run at documentation-build time, so this run injects two things and nothing else: the detector returns a scripted timeline of face / no-face, and the clock is a deterministic 30fps counter. Every branch below is the shipped code's own logic — webbrowser.open is stubbed so no tab really opened. The grace period, the one-shot trigger, the cooldown suppressing a second episode, and the recovery are all real:
opencv 4.14.0 · simulated 30fps · GRACE=1.5s COOLDOWN=6.0s (shipped defaults 4.0 / 20.0)
timeline: 1s face → 3s NO face → 1s face → 2.5s NO face → 1s face
------------------------------------------------------------------
[tracker] watching. look at your screen like you mean it.
[tracker] grace=1.5s cooldown=6.0s cam=0
[tracker] press Q in the preview window to quit.
[tracker] doomscrolling detected (1.5s without a frontal face).
[tracker] opening https://jobs.mchire.com/jobs — good luck out there.
>> browser would open: https://jobs.mchire.com/jobs
[tracker] welcome back. application remains open.
[tracker] doomscrolling detected (1.5s without a frontal face).
[tracker] doomscrolling, but on cooldown (2.0s left). this one's free.
[tracker] welcome back. application remains open.
[tracker] camera stopped returning frames. shutting down.
------------------------------------------------------------------
exit code: 0
Read the cooldown line. The second look-away is a genuine doomscroll and the program says so — but it opened no tab, because the previous one was 2.0 seconds inside the cooldown. That is the guard that stops a flaky camera from opening fifty tabs.
Detection accuracy on a live human face was not benchmarked here, and no number on this page claims otherwise. Doing it honestly needs a webcam and a person sitting in front of it, neither of which exists at build time. A photograph was tried and correctly produced zero detections at every angle — but it showed a person looking up and away in low light, which is the negative case, not a control. Synthetic drawn faces do not trigger Haar cascades at all: they need photographic texture. The detector's real-world hit rate is therefore an open question, and the grace period and cooldown exist precisely because it is.
the cascade only fires on faces pointed at the camera, so a head tilted at a phone stops being detected — the classifier's best-known weakness used as the feature rather than worked around.
OpenCV 5.0 removed Haar cascades outright; a plain pip install opencv-python now installs a version this cannot run on.
failing fast with a readable message beats an AttributeError thrown mid-loop after the webcam light has already come on.
the grace period stops a glance away from being punished; the cooldown stops a flaky camera from opening fifty tabs. They guard different failure modes.
otherwise that branch re-evaluates every frame for the same episode and floods the console.
symmetric hysteresis would mean staring at the screen for four seconds to clear a punishment you had already stopped earning.
rejects the tiny spurious detections Haar throws on background texture, which would otherwise reset the stopwatch and mask a real doomscroll.
the program cannot tell an empty chair from a phone tilt, and defending the distinction would need the pose model that was deliberately left out — documented rather than hidden.
a browser that fails to launch should not kill a loop that is holding the camera.
The web version runs the identical cascade in JavaScript: the same haarcascade_frontalface_default.xml converted to a 204KB table, the same 25 stages and 2913 features, the same integral-image evaluation. Click start, allow the camera, and it behaves exactly like the Python original. The video never leaves the page — there is no server, no upload and no storage.
One browser difference: window.open is blocked outside a user gesture, and a doomscroll is by definition not a click — a pop-up is the one delivery method guaranteed to fail. So the default punishment redirects the current tab, after three seconds of visible countdown you can cancel. New-tab and overlay-only modes are there too.
And there is sound. Getting caught starts the shift: fryer timer, drive-thru chime, order bell, griddle sizzle, room hum. All of it is synthesised at runtime from oscillators and generated noise — no audio files ship, nothing is downloaded, and no recorded or branded music is reproduced. It stops the moment you look back at the screen.
python3 -m venv .venv source .venv/bin/activate pip install "opencv-python<5"
The version pin matters — see above. Then:
python doomscroll_tracker.py
A window titled mcdonalds doomscroll tracker opens. Press Q in that window to quit; closing it with the mouse may not register. macOS will ask for camera permission the first time, and the terminal running it needs to be granted access under System Settings → Privacy & Security → Camera.
Everything adjustable is a constant at the top of the file. Nothing else needs reading:
# TUNABLES — retune the entire app from right here.
# --------------------------------------------------------------------------
# Where you end up when you can't stop scrolling.
MCDONALDS_JOBS_URL = "https://jobs.mchire.com/jobs"
# How long your face may be missing before it counts as doomscrolling.
# Too low: blinking away for a sip of coffee gets punished. Too high: you get
# away with a full TikTok. 4 seconds is the sweet spot of cruelty.
GRACE_SECONDS = 4.0
# Minimum gap between two browser openings, even across separate episodes.
# This is the anti-tab-bomb guard for flaky cameras / bad lighting.
COOLDOWN_SECONDS = 20.0
# Which webcam. 0 is the built-in on most machines; try 1, 2, ... for USB cams.
CAM_INDEX = 0
# Haar cascade knobs. Raise MIN_FACE_SIZE if a poster on your wall keeps getting
# detected as a face; lower it if you sit far from the camera.
SCALE_FACTOR = 1.2 # pyramid step; smaller = slower but more thorough
MIN_NEIGHBORS = 5 # higher = fewer false positives, more missed faces
MIN_FACE_SIZE = (80, 80) # px; rejects tiny junk detections
It is one file of 257 lines. These five blocks are the program; the rest is drawing rectangles.
# ---- dependency check ------------------------------------------------
# OpenCV 5.0 dropped Haar cascades. Fail fast and legibly, before we take
# over the camera, rather than throwing AttributeError mid-loop.
if not hasattr(cv2, "CascadeClassifier"):
log(f"OpenCV {cv2.__version__} has no Haar cascades — 5.0 removed them.")
log('This script needs the 4.x line: pip install "opencv-python<5"')
return 1
# ---- detector --------------------------------------------------------
# Loaded from the wheel's bundled data dir, so there's nothing to download.
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
face_cascade = cv2.CascadeClassifier(cascade_path)
if face_cascade.empty():
log(f"Could not load the face cascade from: {cascade_path}")
log("Reinstall with: pip install --force-reinstall opencv-python")
cap.release()
return 1
log("watching. look at your screen like you mean it.")
log(f"grace={GRACE_SECONDS}s cooldown={COOLDOWN_SECONDS}s cam={CAM_INDEX}")
log("press Q in the preview window to quit.")
face_present = len(faces) > 0
# -------------------------------------------------------------
# STATE MACHINE
# -------------------------------------------------------------
if face_present:
# A frontal face means you're looking at the screen. Refresh the
# stopwatch every single frame it's visible.
last_seen = now
if state == STATE_DOOMSCROLLING:
# Recovery is immediate — no grace period on the way back.
state = STATE_FOCUSED
browser_triggered = False
log("welcome back. application remains open.")
else:
# No frontal face. Could be a phone tilt, could be you left, could
# be the lighting. Either way the clock is running.
if state == STATE_FOCUSED and (now - last_seen) > GRACE_SECONDS:
state = STATE_DOOMSCROLLING
log(
f"doomscrolling detected "
f"({now - last_seen:.1f}s without a frontal face)."
)
# -------------------------------------------------------------
# PUNISHMENT
# Fires at most once per episode (browser_triggered), and never more
# often than COOLDOWN_SECONDS apart no matter how many episodes.
# -------------------------------------------------------------
if state == STATE_DOOMSCROLLING and not browser_triggered:
since_last_open = now - last_browser_open
if since_last_open >= COOLDOWN_SECONDS:
log(f"opening {MCDONALDS_JOBS_URL} — good luck out there.")
try:
webbrowser.open(MCDONALDS_JOBS_URL)
except Exception as exc: # never let a browser hiccup kill the loop
log(f"couldn't open the browser: {exc}")
last_browser_open = now
browser_triggered = True
else:
# Still on cooldown. Mark the episode as spent anyway so we
# don't re-check this branch every frame for the same episode.
log(
f"doomscrolling, but on cooldown "
f"({COOLDOWN_SECONDS - since_last_open:.1f}s left). this one's free."
)
browser_triggered = True
# -------------------------------------------------------------