Before you startYou 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
011. 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.
022. 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.
033. 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.
044. 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.
055. 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| Mistake | What you see | Fix |
|---|
| Key pasted in the YAML | Key visible in repo history | Rotate it, move it to secrets.LZ_KEY |
|---|
Odd minute like 0 0 | Run starts 20 minutes late | Pick 17 6; the top of the hour is crowded |
|---|
| Repo idle for 60 days | GitHub pauses the schedule | Commit something, or re-enable it in the Actions tab |
|---|
| Loop stops on an empty page only | Endless run at the page cap | Also stop when len(rows) >= total |
|---|
| Exit code ignored | Green check, empty folder | Use sys.exit on ok: false |
|---|
| Commit fails on no change | Red run for nothing | Add || 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.