sk heavy industries

journal club to iphone active

automationmacospythonplaywrighthome-ops

Journal Club is a membership site where each episode is a short audio discussion of a research paper. It plays in the browser with no download or podcast feed. This tool signs in as me (you'll need a paid account), pulls each new episode into an iCloud Drive folder, and lets Apple carry it to the Files app on my phone. No app on the phone, no server in the middle. Full write-up on the blog: Getting Journal Club onto my phone.

 journalclub.io  (my own login, in a real browser)
        │  saved session, headless after the first run
        ▼
 sync_journalclub.py
        ├─ list episodes, skip the ones already synced
        ├─ audio    → check the bytes, keep only real media
        ├─ write-up → print the page to PDF with Chromium
        └─ paper    → resolve the DOI through a chain of PDF sources
        │
        ▼
 iCloud Drive/Journal Club  →  Files app on the iPhone

Signing in once

The site has no API, so the tool drives a real browser with Playwright and keeps a signed in profile on disk. The first run opens a window and waits while I log in. After that it reuses the saved session and runs headless. The password is never asked for by the script and never stored by it.

def restore_auth(context):
    if AUTH_FILE.exists():
        context.add_cookies(json.loads(AUTH_FILE.read_text())["cookies"])

Keeping only real files

A download can return an HTTP 200 and still not be the file I asked for. A login wall or a publisher block will hand back an HTML page with a success code. So nothing is trusted on the status alone. Each file is checked by its leading bytes before it is kept and a stray web page never lands on the phone pretending to be an episode.

def is_audio(body, content_type):
    head = body[:16].lstrip().lower()
    if head.startswith((b"<!doctype html", b"<html")):
        return False                        # a web page, not audio
    return (
        body.startswith(b"ID3")                                   # mp3 tag
        or (len(body) > 1 and body[0] == 0xFF and body[1] & 0xE0 == 0xE0)  # mp3 frame
        or body[4:8] == b"ftyp"                                   # m4a / mp4
        or body.startswith((b"OggS", b"fLaC"))
    )

The reading copy and the paper

For the write-up I print the episode page to PDF with Chromium so there is a clean copy to read alongside the audio. The paper is the hard part: the episode links to it by DOI, and a DOI is a redirect, not a file. Resolving it means trying several routes from a direct request to Crossref metadata to publisher specific URL shapes, and falling back to loading the candidate in the signed in browser tab when a plain request gets blocked. The blog post walks through the whole chain.

Running it nightly

The tool keeps a small state file of finished episodes, so a run only fetches what is new and can be stopped and resumed. A file lock keeps two runs from overlapping and it takes at most seven new episodes at a time by default. For the unattended run there is one fixed wrapper scheduled_sync.sh. The agent that runs it on a schedule gets exactly one command to call, not a shell. On my setup that agent is lil-sis, the local model on a Mac Studio that handles my other nightly jobs through OpenClaw, and it posts a short report to Discord when the sync finishes.

Stack: Python, Playwright (Chromium), iCloud Drive as the transport, Crossref for paper lookups, cron via the OpenClaw gateway and lil-sis.