Published on

Claiming free game rewards with a Hermes cron job and a headless browser

Authors
On this page

I play Marvel Contest of Champions. The web store has free offers that come and go through the day, and I kept forgetting to check. So a scheduled job checks for me, every 6 hours.

It is small: one cron entry, one shell line, one Playwright script. The part worth writing about is how it behaves when things go wrong, because the logs show that happens often.

The pieces

Hermes cron (0 */6 * * *)
  -> claim_crystals.sh        one line: run the python script
     -> claim_crystals.py     Playwright, headless Chromium, saved login
  -> a small model reads the output and writes the report

The cron job has a script field. Hermes runs the script first and hands its output to the model, which writes a short report. The model is a cheap one (a Gemini flash model through 9Router), because the job is only reading a log line.

Step 1: reuse the login

The script saves the browser state (cookies and local storage) after every run and loads it on the next one. It never logs in unless it has to:

context_kwargs = {"viewport": {"width": 1280, "height": 800}}
if os.path.exists(STORAGE_PATH):
    context_kwargs["storage_state"] = STORAGE_PATH
context = browser.new_context(**context_kwargs)

To know whether the saved login still works, it looks for an "ACCOUNT" element that is visible on the page:

logged_in = page.evaluate("""() => {
    return Array.from(document.querySelectorAll('*'))
        .some(el => el.innerText && el.innerText.trim() === 'ACCOUNT' && el.offsetWidth > 0);
}""")

If it is there, the script says "Already logged in." and moves on. If not, it runs the login branch and saves fresh state. Checking for a visible element is more reliable than checking for a cookie, because a cookie can exist and still be expired.

Step 2: claim until the button changes

The claim loop is capped at 10 attempts, so a page that never changes cannot trap it:

claimed = 0
for attempt in range(10):
    free_btns = page.locator('.item-action-free .primary-button')
    if free_btns.count() == 0:
        break
    first = free_btns.first
    btn_text = first.inner_text().strip().upper() if first.is_visible() else ""
    if "CLAIM" not in btn_text and "FREE" not in btn_text:
        break   # SOLD OUT or LOGIN: stop
    first.scroll_into_view_if_needed()
    first.click()
    try:
        page.locator('.button-continue').wait_for(timeout=10000)
        page.click('.button-continue')
        claimed += 1
    except Exception:
        page.reload()
print(f"Done. Claimed {claimed} offers.")

Two details I like. It reads the button text before clicking, so a "SOLD OUT" button is never pressed. And a claim only counts after the confirmation button appears. If that does not show up, it reloads and tries again, instead of counting a click that did nothing.

The last line, Claimed N offers., is what the report is built from.

What 50 runs look like

Hermes keeps each run as a file. I counted the 50 that are saved, from Sep 22 to Oct 5 (14 days):

  • 40 of the 50 logs report a claim count. Together they claimed 22 offers.
  • 11 of those runs claimed something. The other 29 found nothing to claim.
  • On 11 of the 14 days, at least one offer was claimed. The three days with nothing claimed either had no free offers or had runs that failed, and the logs do not always say which.
  • 11 of the 50 runs were marked failed.

So most runs find nothing. That is fine, since the point of a 6-hour check is to catch the few offers that appear. The 11 failures matter more.

How it fails

The failures in the logs are mostly network errors and login timeouts. One run shows net::ERR_NETWORK_CHANGED during navigation, Chromium's error for the network changing while a page loads.

When the script fails, Hermes passes the error text to the model, and at least two logs show the model running the script again and reporting the second result. For example: the earlier run failed with a network error, the rerun exited 0, logged in, refreshed the cookies, and found 0 offers.

A second, separate script, cron-autoheal.py, watches the cron list for jobs that fail twice in a row. It switches the job to a model known to be healthy. That covers the case where the model provider is the thing that broke, not the script.

Limits

  • The selectors (.item-action-free, .button-continue) belong to the store's markup. A redesign breaks the script, and the "Claimed 0 offers" line looks the same as an honest empty store. A count check against something the page reports would catch it, as I wrote in the scraping post.
  • A saved login expires on the site's schedule, not mine.
  • It only claims offers that the store shows as free to a logged-in account. Read the game's terms before you automate anything on your own account.
  • The model adds almost nothing to the claiming. It reads one line and reruns on failure. A plain wrapper with one retry would cover the same ground.

If you want the same thing for another store, the part that carries over is the shape: saved login, a visible-element check, a capped loop, a confirmation before counting, and a line the report can trust.