Runbook

Weekly backlink snapshots on GitHub Actions: a runbook

Goal: every Monday a workflow writes today's referring domains and anchors to the repo. Nothing else. Diffing comes later.

Before you start

You need a repo, a Linkzoid key stored as the secret LZ_KEY, and one domain you own. Budget: about 6 credits per target per run, which is $0.0006. A snapshot is a fact about one month, so weekly runs mostly re-fetch the same index. Working out how to check backlinks to my website this way takes one evening.

The runbook, in order

01

1. Add the secret

Repo settings, then Secrets and variables, then Actions. Name it LZ_KEY. If you paste it into the workflow file instead, rotate the key today.

02

2. Commit the script

Save the Python below as snap.py. It writes data/YYYY-MM-DD/refdomains.json and fails on any response where ok is false.

03

3. Commit the workflow

Use the YAML below. Run it once by hand with the workflow_dispatch trigger and read the log before you trust the schedule.

04

4. Check the first file

Open the JSON. Confirm the row count against total, and compare the number with what the free overview call reports for your domain.

05

5. Add the alert

A failed job emails the repo owner by default. Leave that on. A job that quietly stops is the usual way these setups die.

snap.py

Standard library. The loop ends on the count, never on a guess.

python
import json, os, sys, datetime, pathlib, urllib.request as u
KEY = os.environ['LZ_KEY']
TARGET = sys.argv[1]
def get(**p):
    q = '&'.join(f'{k}={v}' for k, v in p.items())
    url = f'https://linkzoid.pro/api.php?{q}&key={KEY}'
    with u.urlopen(url, timeout=30) as r:
        return json.load(r)
rows, page = [], 1
while True:
    d = get(action='refdomains', q=TARGET, page=page, per=1000)
    if not d['ok']:
        sys.exit(f'bad response: {d}')
    rows += d['rows']
    if not d['rows'] or len(rows) >= d['total']:
        break
    page += 1
day = datetime.date.today().isoformat()
out = pathlib.Path('data') / day
out.mkdir(parents=True, exist_ok=True)
(out / 'refdomains.json').write_text(json.dumps(rows))
print(len(rows), 'rows, balance', d['_credits'])

.github/workflows/snapshot.yml

Cron in GitHub runs in UTC and can start late when the runners are busy. Do not build anything that depends on the minute.

yaml
name: backlink-snapshot
on:
  schedule:
    - cron: '17 6 * * 1'
  workflow_dispatch:
jobs:
  snap:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: python snap.py example.com
        env:
          LZ_KEY: ${{ secrets.LZ_KEY }}
      - run: |
          git config user.name snapshot-bot
          git config user.email "snapshot-bot@localhost"
          git add data
          git commit -m "snapshot $(date -u +%F)" || exit 0
          git push

Mistakes and fixes

What breaks first in a scheduled backlink job
MistakeWhat you seeFix
Key pasted in the YAMLKey visible in repo historyRotate it, move it to secrets.LZ_KEY
Odd minute like 0 0Run starts 20 minutes latePick 17 6; the top of the hour is crowded
Repo idle for 60 daysGitHub pauses the scheduleCommit something, or re-enable it in the Actions tab
Loop stops on an empty page onlyEndless run at the page capAlso stop when len(rows) >= total
Exit code ignoredGreen check, empty folderUse sys.exit on ok: false
Commit fails on no changeRed run for nothingAdd || exit 0 after the commit

What the snapshot is, and is not

It is a monthly picture. Our own crawler refreshes the index about once a month, so two Monday runs in the same month often return the same rows. That is expected.

Weekly cadence is still fine. It keeps the habit alive, and you catch a failing job within seven days instead of thirty. If a link you built on Tuesday is missing on Monday, wait for the next refresh before you panic.

When you have three or four dated folders, move on to the SQLite diff. If you are stuck on a stubborn response code, the rate-limit table lists what each status means for a loop.

Adding anchors and top pages

One action per file keeps diffs readable. Copy the loop in snap.py, change refdomains to anchors or toppages, and write to a second filename. Each costs 2 credits.

Keep them separate. A combined file hides which part changed, and a reviewer scrolling a pull request wants to see one concept per diff. If you track three domains, loop over a short list in the workflow with a matrix, so each target gets its own job and its own failure.

Cheap, boring, repeatable.

Questions that come up in the first week

Can I run this on a private repo for free?
Usually yes, within the monthly minutes GitHub gives free accounts. A snapshot job runs in well under a minute, so a weekly schedule uses a tiny share. Check your own plan's current allowance, because GitHub changes it from time to time.
Where should the key live if several people push?
In an organisation or repository secret, never in the files. Anyone who can edit the workflow can print a secret in a log, so limit who has write access, and rotate the key if somebody leaves the project.
Why did my scheduled run not fire?
Three usual reasons. The workflow file is not on the default branch, the repo was idle for 60 days and GitHub paused the schedule, or the cron string has a typo. Run it once with workflow_dispatch to rule out the script itself.
How big do the data folders get?
Small. A thousand referring domains is a few hundred kilobytes of JSON. A year of weekly files for one domain stays well under a hundred megabytes, which is fine for git, though you can compress older folders if it bothers you.